FlutterでAI音声認識(web socket)ってしてみたいじゃん？

Flutterで何か面白いネタ探ししていました。
そこで見つけたのがこちらの記事
- [【Flutter】iOSアプリからAmiVoiceというAI音声認識を利用してみる。](https://qiita.com/ryo_one/items/e0835169b85b7e19b30a)

なるほどなるほど。音声認識とな。
面白そうということで早速作ってみました。
でもそのまま真似るのは面白くないので、どうせならリアルタイムでやり取りしたい。
なのでWebSocket通信を利用したリアルタイムな音声認識アプリを目指します。

## 事前準備
今回はビルドターゲットをiOSとします（私がMac環境なので）

# AmiVoice APIに登録
【結論】しましょう。じゃないとAPIが使えません
ユーザーIDとauthTokenが必要となります
（2025年5月15日現在、60分までは無料ですので試すには優しい）

# マイクを許可しよう

iPhoneのマイクを許可しましょう。気を付けることは以下の通り
-  plistにマイクを使うことを明記
-  Podfileを書き換え
-  Flutter側からはマイクの使用許可をリクエストする

### plistにマイクを使うことを明記
```
<dict>
...
    <key>NSMicrophoneUsageDescription</key>
    <string>このアプリは音声入力のためにマイクを使用します。</string>
...
</dict>
```
こんな感じ

### Podfileを書き換え
一番下に`post_install do |installer|`という部分があります
これを以下に書き換えましょう
```
post_install do |installer|
  installer.pods_project.targets.each do |target|
    flutter_additional_ios_build_settings(target)

    target.build_configurations.each do |config|
      # You can remove unused permissions here
      # for more information: https://github.com/BaseflowIT/flutter-permission-handler/blob/master/permission_handler/ios/Classes/PermissionHandlerEnums.h
      # e.g. when you don't need camera permission, just add 'PERMISSION_CAMERA=0'
      config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= [
        '$(inherited)',

        ## dart: PermissionGroup.microphone
        'PERMISSION_MICROPHONE=1',
      ]

    end
  end
end
```

### Flutter側からはマイクの使用許可をリクエストする

なんでも、上記二つの設定ではマイクを「使えるようにした」だけであり、許諾は別でとらなければいけないとかなんとか。
Flutter側で`flutter pub add permission_handler`を導入してマイクの使用許可をとりましょう

```dart
import 'package:flutter/material.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:src/view/recodeView/recodeView.dart';

Future<void> requestMicrophonePermission() async {
  final status = await Permission.microphone.request();
  if (status.isDenied) {
    throw Exception('Microphone permission is denied');
  }
  if (status.isPermanentlyDenied) {
    openAppSettings(); // ユーザーが手動で許可するよう促す
  }
}

void main() async{
  WidgetsFlutterBinding.ensureInitialized();
  await requestMicrophonePermission();
  runApp( MaterialApp(home: RecodeView(),));
}

```

初回アプリ起動時にローカルアクセス許可とマイク許可が取れます。
※RecodeViewは自作UIクラス

# ライブラリを入れよう
permission_handlerに加え、`mic_stream`と`web_socket_channel`も導入しましょう。
・・・え、`flutter_sound`じゃないのかって？
PCMデータだけ取れればいいのでmic_streamでいいです。お試しですし。

# asset/login.jsonを用意
assetフォルダを作り、AmiVoiceAPIログイン情報を記入しましょう
ソース上にベタで挿入してもいいですが、まぁいいじゃないですか
```JSON
{
  "userId": "xxxxxxxxxユーザーIDxxxxxxxxxxxxx",
  "authToken": "xxxxxxxx結構長いトークン文字列xxxxxxxxxxxxxxxxxxxxxx",
  "apiUrl": "wss://acp-api.amivoice.com/v1/"
}

```

ちなみにapiUrlはAmiVoice側にログを保存させて良いかどうかで変わります。
ログに保存すると学習に使われるようです。お値段も変わります。（安くなる）
>「ログ保存」とは、当社のサーバに AmiVoice API 利用者が送信した音声データと音声認識処理した結果を保存することを指しています。ログ保存ありの場合、当社に音声データ、および、音声認識結果のテキストデータを提供することに同意したことになります。ログ保存を許可するかどうかは、ユーザー自身が API を呼び出すときに決めることができます。

私の美声(諸説あり)が学習に使われると思うと背徳感がありますね？
そうそう、assetフォルダを用意したならyamlにちゃんと追加しておきましょう
```yaml
  assets:
    - asset/login.json
```

# WebSocket周りとマイク周りのプログラム、ほしい？
作りました（chatGPTが8割)
```dart
import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show rootBundle;
import 'package:web_socket_channel/web_socket_channel.dart';
import 'package:mic_stream/mic_stream.dart';

enum Engine {
  general("-a-general"),
  medical("-a-medical"),
  other("-a-general");

  const Engine(this.engineName);

  final String engineName;
  static final Map<String, Engine> _map = {for (final v in Engine.values) v.engineName: v};

  static Engine getEnginFromString(String value) {
    return _map[value] ?? Engine.other;
  }
}

mixin MixinWebSocket<T extends StatefulWidget> on State<T> {
  String? userId;
  String? authToken;
  String? apiUrl;
  bool isRecording = false;
  bool isEroor = false;
  String resultText = '';
  String resultFullText = '';
  String resultErrorText = '';
  List<String> resutlTextList = [];

  Stream<List<int>>? _micStream;
  StreamSubscription<List<int>>? _micSubscription;
  WebSocketChannel? _channel;

  void safeSetState(VoidCallback fn) {
    if (mounted) {
      setState(fn);
    }
  }

  Future<void> loadApiConfig() async {
    final String jsonString = await rootBundle.loadString('asset/login.json');
    final Map<String, dynamic> jsonMap = json.decode(jsonString);
    if (jsonMap.containsKey("userId")) {
      userId = jsonMap["userId"];
    }
    if (jsonMap.containsKey("authToken")) {
      authToken = jsonMap["authToken"];
    }
    if (jsonMap.containsKey("apiUrl")) {
      apiUrl = jsonMap["apiUrl"];
    }
  }

  bool nullCheck() {
    if (apiUrl == null || userId == null || authToken == null) {
      return false;
    }
    return true;
  }

  // 接続の初期化
  Future<bool> initializeWebSocket() async {
    if (!nullCheck()) return false;
    final uri = Uri.parse(apiUrl!);
    _channel = WebSocketChannel.connect(uri);

    _channel?.stream.listen(
      (message) {
        print(message);
        handleWebSocketMessage(message);
      },
      onError: (error) {
        print(error);
      },
      onDone: () {
        print("WebSocket切断");
      },
    );
    return true;
  }

  void stopRecording() async {
    // eコマンドでセッション終了
    cmdE();
    await _micSubscription?.cancel();
    _micSubscription = null;
    await _channel?.sink.close();
    _channel = null;
    safeSetState(() {
      isRecording = false;
    });
  }

  // 開始コマンド送信
  Future<void> cmdS({required Engine engin}) async {
    String cmd = "s";
    final sCommand = '$cmd LSB16K ${engin.engineName} profileId=:${userId} authorization=${authToken}';
    _channel?.sink.add(sCommand);

    safeSetState(() {
      isRecording = true;
      resultText = '';
      resutlTextList.clear();
      resultFullText = "";
      resultErrorText = "";
    });

    // マイクから音声データ取得
    _micStream = await MicStream.microphone(
      audioSource: AudioSource.DEFAULT,
      sampleRate: 16000,
      channelConfig: ChannelConfig.CHANNEL_IN_MONO,
      audioFormat: AudioFormat.ENCODING_PCM_16BIT,
    );

    _micSubscription = _micStream?.listen((List<int> data) {
      if (_channel != null) {
        final pData = _buildPCommand(data);
        _channel!.sink.add(pData);
      }
    });
  }

  // 終了コマンド送信
  Future<void> cmdE() async {
    final sCommand = 'e';
    _channel?.sink.add(sCommand);
  }

  Uint8List _buildPCommand(List<int> pcmData) {
    final prefixed = Uint8List(pcmData.length + 1);
    prefixed[0] = 0x70; // 'p'
    prefixed.setRange(1, prefixed.length, pcmData);
    return prefixed;
  }

  void handleWebSocketMessage(String message) {
    if (message.isEmpty) return;
    if (!message.startsWith("A ")) return;

    // 残りをJSONとして解析
    String jsonString = message.substring(2); // 1文字目 + 空白をスキップ
    try {
      Map<String, dynamic> jsonMap = jsonDecode(jsonString);

      // 例：認識されたテキストを取り出す
      String recognizedText = jsonMap['text'] ?? '';
      safeSetState(() {
        resultFullText += recognizedText + "\n";
      });
    } catch (e) {
      print('JSONのパースに失敗しました: $e');
    }
  }
}

```

mixinクラスですので使いたいページにwithすれば使えます。
重要なのは、**PCMデータがリトルエンディアンかビッグエンディアンか**、です。
これによって`LSB16K`か`MSB16K`かでコマンドが変わります。

# 搭載
ではUI画面を作っていきましょう
```dart
import 'package:flutter/material.dart';
import 'package:src/component/webSocket/mixinWebSocket.dart';
import 'package:flutter/services.dart';

class RecodeView extends StatefulWidget {
  const RecodeView({super.key});

  @override
  State<RecodeView> createState() => _RecodeViewState();
}

class _RecodeViewState extends State<RecodeView> with MixinWebSocket<RecodeView> {
  @override
  void initState() {
    // TODO: implement initState
    super.initState();
    loadApiConfig();
  }

  @override
  void dispose() {
    stopRecording();
    super.dispose();
  }

  Future<void> _toggleRecording() async {
    if (!isRecording) {
      initializeWebSocket();
      cmdS(engin: Engine.general);
    } else {
      stopRecording();
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('AmiVoice',style: TextStyle(color: Colors.white),),
        actions: [
          IconButton(
              onPressed: () async {
                final data = ClipboardData(text: resultFullText);
                await Clipboard.setData(data);
                ScaffoldMessenger.of(context).showSnackBar(
                  SnackBar(
                    content: Text("コピーしました"),
                  ),
                );
              },
              icon: Icon(Icons.copy,color: Colors.white,)),
        ],
        backgroundColor: Colors.blueAccent,
      ),
      body: SingleChildScrollView(
        child: Text(resultFullText),
      ),
      floatingActionButton: FloatingActionButton.large(
        onPressed: _toggleRecording,
        backgroundColor: isRecording ? Colors.red : Colors.blue,
        foregroundColor: Colors.white,
        // ← アイコン色
        shape: const CircleBorder(),
        child: Icon(isRecording ? Icons.mic : Icons.mic_none),
      ),
      floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
    );
  }
}

```
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3737064/94c5188b-a0ba-4d09-b78e-98e8f531160c.png" width="250">

こんな画像になったでしょうか？

# 使い方
フロートボタンを押すと録音＋API接続します。再度押すと停止。
喋ると裏で通信し、しゃべった言葉が表示されます。（A イベントパケットのみを現在は表示するようにしているため、文節ごとにデータが飛んでくるようです。喋り続けるとAイベントパケットは飛んでこないので、適度に一呼吸おいて喋りましょう）

この段階でぶっちゃけかなり使える、と驚愕しているんですが、どうせならもう一工夫欲しい

# もし工夫するなら
- chatGPT先生に「誤字脱字があるかもしれない文章を、なおして」と依頼する
これするだけで体感正答率が99%になりました。またどこを修正したのか、というところまで教えてくれるので修正前後の比較もしやすい
- マイクを変える
iphoneのマイクでもいいんですが、会議とか議事録ようだとちょっと力不足。外部マイクを接続すればもっともっと使えるようになるはずです（ただし外部マイクがちゃんと認識されるかは不明）


# というわけで
いかがだったでしょうか。
ここまで2日でできました。chatGPT先生様々です。

世の中にはAI音声アプリが溢れているわけですが、ここまで自分で作れるとなれば
かなり高品質なものがとてもリーズナブルに使えそうですね。（60分無料が終わっても、汎用型であれば1時間あたり90円)

もちろんWEB会議などを録音したい、といったニーズとはちょっと難しいですが、対面での言質取り(?)だったりラジオを文字起こしして、敢えて文字で楽しんだりと、いろいろできそうです。

個人的には学校現場で留学生向けに、先生の日本語を文字起こし→翻訳して理解度サポートとかも面白いかもです。
大学の講義とかならよく録音している人とかいますが、ぶっちゃけそんなことより文書化してあれば振り返りももっと楽なはず。

そういうアプリはすであると思いますが、まぁそんなこと言ったら世の中は車輪の再発明のオンパレードですので、売れないことはないんじゃないですか？（適当）

ではでは、また面白いネタがあればお会いしましょう