録画アプリにUSBで起動する機能をつける

前に作った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