前に作ったASRのPoCでは音声指示すると、THINKLET側で録画の開始と停止の音声を出すだけのダミーだった。

FlutterでUSBシリアル -9-|kinneko|pixivFANBOX
https://kinneko.fanbox.cc/posts/9712570
FlutterでUSBシリアル -9- 2025年4月23日 08:00 ¥100 kinneko
さすがにそういうデモもないかなということで、実際に録画できるデモ環境を作る。とりあえず、録画できるアプリは作った。なんかいろいろ面倒だったので、記録はつけたけど記事にはしないで終わった。
flutter_volume_controllerを使ってボタンのイベントを取得している。THINKLETにはボタンの機能をカスタムすることができるのだけど、それだと音量調整ができなくなるし、使えるアプリが固定になってしまうので、いろいろよろしくない。音量操作を分岐して取得しているので、押すとボリュームも変わるところがよくないけど、そこは妥協することにする。
現在の録画プログラム。とりあえず、これに特定のVID/PIDのUSBシリアルを差し込むとアプリを起動するようにする。
初回はパーミッションの確認と、デバイスとアプリを紐づける操作が必要になる。後者はシステム署名してもroot化しないとパスできない感じ。THINKLETではいろいろつらいね。
% cat lib/main.dart
import 'dart:async';
import 'dart:io';
import 'package:camera/camera.dart';
import 'package:flutter/material.dart';
import 'package:audioplayers/audioplayers.dart';
import 'package:media_store_plus/media_store_plus.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:flutter_volume_controller/flutter_volume_controller.dart';
// 録画開始/停止の保留意図(トップレベルに宣言)
enum _Intent { none, start, stop }
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
runApp(const RecorderApp());
}
class RecorderApp extends StatefulWidget {
const RecorderApp({super.key});
@override
State<RecorderApp> createState() => _RecorderAppState();
}
class _RecorderAppState extends State<RecorderApp> {
CameraController? _controller;
bool _isRecording = false;
bool _isInitialized = false;
final AudioPlayer _player = AudioPlayer()..setReleaseMode(ReleaseMode.stop);
// 直列実行用のシーケンス
Future<void> _audioSeq = Future.value();
// 直列再生ヘルパー
Future<void> _playAssetQueued(String assetPath) {
_audioSeq = _audioSeq.then((_) async {
await _player.stop();
await _player.play(AssetSource(assetPath));
await _player.onPlayerComplete.first;
});
return _audioSeq;
}
double _lastVol = 0.5;
static const double _th = 0.02;
// 誤トリガ抑制(クールダウン)のための変数
DateTime _lastTrig = DateTime(0);
static const _cooldown = Duration(milliseconds: 500);
final MediaStore _mediaStore = MediaStore();
// 処理中フラグと保留意図
bool _isProcessing = false;
_Intent _pending = _Intent.none;
@override
void initState() {
super.initState();
_bootstrap();
}
@override
void dispose() {
_controller?.dispose();
_player.dispose();
FlutterVolumeController.removeListener();
super.dispose();
}
Future<void> _bootstrap() async {
await MediaStore.ensureInitialized();
MediaStore.appFolder = "Thinklet";
await _requestPermissions();
await FlutterVolumeController.setAndroidAudioStream(stream: AudioStream.music);
await _initCamera();
_lastVol = await FlutterVolumeController.getVolume() ?? _lastVol;
FlutterVolumeController.addListener(_onVolumeChanged);
if (mounted) {
setState(() {
_isInitialized = true;
});
}
// 起動時
await _playAssetQueued('voice/appwake.wav');
// 音声コンテンツの権利表示
await _playAssetQueued('voice/copyright.wav');
}
// 誤トリガ抑制(クールダウン)の実装
void _onVolumeChanged(double v) async {
final now = DateTime.now();
final dv = v - _lastVol;
_lastVol = v;
if (_isProcessing) {
if (dv > _th) _pending = _Intent.start;
else if (dv < -_th) _pending = _Intent.stop;
return;
}
if (now.difference(_lastTrig) < _cooldown) return;
if (dv > _th) {
_lastTrig = now;
await _startFlow();
} else if (dv < -_th) {
_lastTrig = now;
await _stopFlow();
}
}
Future<void> _requestPermissions() async {
final req = <Permission>[
Permission.camera,
Permission.microphone,
if (Platform.isAndroid) ...[
Permission.videos,
Permission.storage,
]
];
// 権限チェックの強化
final st = await req.request();
if (!(st[Permission.camera]?.isGranted ?? false) ||
!(st[Permission.microphone]?.isGranted ?? false)) {
// 本来はユーザーにダイアログで通知すべき
debugPrint("必須の権限が許可されませんでした。アプリを終了します。");
return;
}
}
Future<void> _initCamera() async {
final cams = await availableCameras();
if (cams.isEmpty) return;
final cam = cams.firstWhere(
(c) => c.lensDirection == CameraLensDirection.back,
orElse: () => cams.first,
);
_controller = CameraController(cam, ResolutionPreset.high, enableAudio: true);
try {
await _controller!.initialize();
} on CameraException catch (e) {
debugPrint("カメラの初期化に失敗しました: $e");
}
}
// 開始フローの順序を修正
Future<void> _startFlow() async {
if (_isRecording || _controller == null || !_controller!.value.isInitialized) return;
if (_controller!.value.isRecordingVideo) return;
try {
if (mounted) setState(() => _isProcessing = true);
// 録画開始
await _playAssetQueued('voice/start.wav');
try {
await _controller!.startVideoRecording();
} on CameraException catch (e) {
debugPrint("録画開始に失敗: $e");
return;
}
if (mounted) setState(() => _isRecording = true);
} finally {
if (mounted) setState(() => _isProcessing = false);
_drainPending();
}
}
Future<void> _stopFlow() async {
if (!_isRecording || _controller == null || !_controller!.value.isRecordingVideo) return;
try {
if (mounted) setState(() => _isProcessing = true);
// 押下直後の通知(待たない)
_player.stop();
// 停止ボタン直後(保存処理は並行して進めてOK)
_playAssetQueued('voice/stop.wav'); // awaitは不要(順番だけ保証される)
// 録画停止
final XFile tempVideo = await _controller!.stopVideoRecording();
if (mounted) {
setState(() { _isRecording = false; });
}
// 保存処理
final directory = tempVideo.path.substring(0, tempVideo.path.lastIndexOf('/'));
final newPath = '$directory/${DateTime.now().millisecondsSinceEpoch}.mp4';
final File renamedVideoFile = await File(tempVideo.path).rename(newPath);
if (Platform.isAndroid) {
final SaveInfo? result = await _mediaStore.saveFile(
tempFilePath: renamedVideoFile.path,
dirType: DirType.video,
dirName: DirName.movies,
);
debugPrint('動画の保存結果: ${result != null ? "成功" : "失敗"}');
// MediaStore の保存後に安全に削除
if (result != null) {
try {
await renamedVideoFile.delete();
} catch (e) {
debugPrint("一時ファイルの削除に失敗しました: $e");
}
}
}
// 完了通知(保存完了後)
await _playAssetQueued('voice/dataready.wav');
} catch (e) {
debugPrint("録画停止処理中にエラーが発生: $e");
if (mounted) {
setState(() { _isRecording = false; });
}
} finally {
if (mounted) setState(() => _isProcessing = false);
_drainPending();
}
}
// CameraX の停止→解放→再構成が落ち着くのを待ってから保留意図を消化
void _drainPending() {
if (_pending == _Intent.none) return;
// 連打抑制のためクールダウン起点を更新
_lastTrig = DateTime.now();
final intent = _pending;
_pending = _Intent.none;
_schedulePending(intent);
}
Future<void> _schedulePending(_Intent intent) async {
// CameraX のエンコーダ解放などが完了するまで僅かに待つ
await Future.delayed(const Duration(milliseconds: 250));
if (!mounted) return;
if (_controller == null || !_controller!.value.isInitialized) return;
// 最新状態に整合する場合のみ実行
if (intent == _Intent.stop && _isRecording) {
await _stopFlow();
} else if (intent == _Intent.start && !_isRecording && !_controller!.value.isRecordingVideo) {
await _startFlow();
}
}
Future<void> _playAsset(String assetPath) async {
await _player.stop();
await _player.play(AssetSource(assetPath));
await _player.onPlayerComplete.first;
}
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(title: const Text('THINKLET Recorder')),
body: Center(
child: (_isInitialized && _controller != null && _controller!.value.isInitialized)
? CameraPreview(_controller!)
: const CircularProgressIndicator(),
),
),
);
}
}
ライブラリは、"usb_serial: ^0.5.2" を使う。
pubspecにusb_serialを追加
% vi pubspec.yaml
dev_dependencies:
usb_serial: ^0.5.2
AndroidManifest設定
vi android/app/src/main/AndroidManifest.xml
<manifest ...>
<uses-feature android:name="android.hardware.usb.host" />
<application ...>
<activity
android:name="io.flutter.embedding.android.FlutterActivity"
android:exported="true">
<intent-filter>
<action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED"/>
</intent-filter>
<meta-data
android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED"
android:resource="@xml/device_filter"/>
</activity>
</application>
</manifest>
デバイスフィルタ作成
ターゲットには、M5Stack ATOMS3 Liteを使用。デフォルトはこんな感じ。そのまま利用する。
USB JTAG/serial debug unit:
製品ID 0x1001 = 4097
製造元ID 0x303a = 12346
android/app/src/main/res/xml/device_filter.xmlを新規作成する。
% mkdir -p android/app/src/main/res/xml/
% vi android/app/src/main/res/xml/device_filter.xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- ATOMS3 LITE Vendor=0x303A, Product=0x1001 -->
<usb-device vendor-id="12346" product-id="4097" />
<!-- <usb-device vendor-id="0x303A" /> -->
</resources>
これで起動だけならできるはず。
USBは使用するので、THINKLETは無線でadb接続するのに切り替える。
% adb tcpip 5555
restarting in TCP mode port: 5555
% adb shell ip addr show wlan0 | grep 'inet '
inet 192.168.0.19/24 brd 192.168.0.255 scope global wlan0
% adb connect 192.168.0.19:5555
connected to 192.168.0.19:5555
% scrcpy
デバッグモードでアプリを書き込み。
% flutter clean && flutter pub get && flutter run
ちゃんと確認ダイアログが出た。

チェックをつけてOKする。

起動は問題なし。バージョンを書き込んでgithubに登録。
% vi pubspec.yaml
version: 2.0.0+1
% git add pubspec.yaml android/app/src/main/AndroidManifest.xml android/app/src/main/res/xml/device_filter.xml
% git commit -m "USBデバイスに連動して起動 v2系開始"
% git push origin main
100円投げ銭 https://kinneko.booth.pm/items/1963720
オリジナル投稿:
録画アプリにUSBで起動する機能をつける|kinneko|pixivFANBOX
https://kinneko.fanbox.cc/posts/10511925



