AIが空耳する日 — 音声通話アプリの誤検知対策、文字起こし崩壊の犯人探し、そしてコンテナ化

前回、Push-to-Talkのリアルタイム音声通話アプリを1日で作った。その後このアプリは、回線を2方式(WebSocket中継 / WebRTC直結)、会話モードを2方式(PTT / ハンズフリー通話)に拡張し、電話のように話せるところまで育っている。

今回はその続き。3つの問題を潰した記録である。うち2つは、ログから犯人を追い詰める謎解きになった。

問題1: 机を叩くとAIが反応する

ハンズフリー通話モードはサーバー側のVAD(発話検知)が「話し始め・話し終わり」を自動で拾う。これが敏感すぎて、机にマグカップを置く音やキーボードの打鍵だけで「発話」と認定されてしまう。AIの応答中に物音がすると、発話と誤認されて応答が中断される(誤バージイン)のが特に痛い。

まず再現から入った。sayコマンドで合成した音声は正しく認識されることを確認した上で、衝撃音を合成で作って送り込む。ノック(低域のドン×3)、フルスケールの拍手、キーボード連打、残響の尾を持たせた実マイク模擬——ところが、どれもVADは騙されなかった。クリーンな合成衝撃音に対して、semantic_vadは思いのほか頑強だったのである。

つまり誤検知は実マイク特有の何か(AGCが持ち上げた残響、マイク筐体への振動直撃)で起きている。こちらで再現できない以上、対策は正攻法を選ぶ。OpenAIのセッション設定には入力ノイズリダクション(noise_reduction: near_field)があり、VADと文字起こしの手前で入力を掃除してくれる。ブラウザ側のnoiseSuppressionと合わせて2段構えにした。

検証で面白かったのは「拍手3発→即発話」の連結音源だ。ノイズリダクションが発話の頭まで食ってしまわないかを見るためだが、拍手は無視され、直後の発話だけが正確に文字起こしされた。実機でも効果があり、この問題は収束した。

問題2: WebSocket回線に切り替えると会話が壊れる

デフォルトのWebRTC回線では快調に話せるのに、WebSocket回線に切り替えた途端、会話が噛み合わなくなった。ログがすごい。

あなた「ショーターのことです」→ AI(正しく応答)
—— 回線をWebSocketに切替 ——
あなた「ショーン・アビブ。」
あなた「シュガーとチョコレートを混ぜてください。」
あなた「自由に会える世界で満足してる。」

「ショーター(空売り筋)」の話をしていたのに、切替後は発話が音韻的に無関係な文として文字起こしされる。AIは「声がこもっている」「もう少し近くで話して」と訴える。こもった声+デタラメな認識——これは速度のずれた音声をASRが「それっぽい日本語」に無理やり解釈するときの症状だ。

疑うべきは切替ロジックか、音声経路か。ここで前回も使った偽マイク(getUserMediaを差し替え、合成WAVを流し込む)でE2E再現を試みたところ、意外な結果が出た。偽マイクではWebRTC→WebSocket切替後も文字起こしは完璧なのである。切替ロジックはシロ。問題は実デバイス——「iPhoneのマイク」(macOSの連携マイク)に依存していた。

残る容疑者は、WebSocket回線だけが通る特別な経路だ。このアプリの録音チェーンは、24kHz指定のAudioContextを作り、ブラウザ内蔵のリサンプラに変換を任せていた。しかしChromeでサンプルレートを強制すると、OS(CoreAudio)の入力デバイス設定にまで介入する。iPhone連携マイクのような仮想デバイスでは「実際のレートとラベルがずれた音声」になり得る——速度がずれ、こもり、ASRが作話する。症状と完全に一致する。WebRTC回線が無事だったのは、Chrome内蔵のWebRTCスタック(ネイティブレート/Opus)を使うのでこの経路を通らないからだ。

対策は「レートを強制しない」。AudioContextは常にデバイスのネイティブレートで開き、24kHz化はAudioWorklet側の面積平均リサンプラに一本化した。44.1kHz→24kHzという非整数比のリサンプルでも文字起こしが正確なことをE2Eで確認して収束。実機でも直った。

教訓: 強制は介入である。「24kHzで欲しいから24kHzで開く」は素直な発想だが、その指定がOSのデバイス設定を書き換えるとき、行儀の悪いデバイスは壊れた音を返してくる。欲しい形への変換は、自分の手元(ワークレット)でやるほうが安全だった。

コンテナ化と、幽霊プロセス事件

機能が一段落したのでコンテナ化に着手した。最終目標は「terraform applyしたらサービスが立つ」。フリーランスとして「何作った、これ作った、デプロイするから待ってろ、ほらね」がやりたいのである。今回はその土台、ローカルコンテナ化まで。

uvベースのDockerfileは素直に書けた。依存を先にuv sync --frozenしてレイヤーキャッシュを効かせ、秘密(.env)はenv_fileで注入、会話履歴DBはボリュームの/dataへ。コンテナ内のディレクトリ配置はリポジトリと同一にして、パスの相対関係を保つ。

事件は検証で起きた。コンテナ経由の音声E2Eは通るのに、セッション設定に入れたはずのnoise_reductionがnullで返ってくる。コンテナ内のコードを確認しても設定は入っている。生成されるJSONも正しい。コンテナ内から直接OpenAIに同じJSONを送ると、ちゃんとnear_fieldが返る。では中継サーバーが送る瞬間に何が起きているのか——デバッグパッチを当てても、送信ログが出ない。それどころか、コンテナのログに接続ログが1行もない

ここで気づいた。コンテナを止めてもポート8001が応答する。正体は、以前の検証で殺し損ねた旧コードのuvicornが127.0.0.1:8001に居座り、検証トラフィックを全部横取りしていたのだった。「コンテナの検証」だと思っていたものは、最初から最後まで幽霊(マージ前の古いコード)との対話だった。道理でnoise_reductionが無いわけである。

幽霊を退治して再検証したら全部正常。以後、検証前のlsof確認と、docker logsに接続ログが出ていることの確認を約束事にした。「テストが通った」と「テストしたつもりの相手と話していた」は別物——分散システムの格言みたいな話が、ローカルの8001番で起きた。

おまけ: 0.0.0.0という罠

コンテナ化すると起動ログがUvicorn running on http://0.0.0.0:8000になる。これをクリックして開くと、ログインが無限ループする。0.0.0.0はサーバーの待ち受けアドレスであってブラウザで開く場所ではなく、CognitoのコールバックURLにも当然登録されていない。さらに-p 8001:8000で起動していれば、ログの8000はコンテナ内部の番号なので接続すらできない。

対策は二段。サーバー側に「0.0.0.0で開かれたらlocalhostへリダイレクト」のガードを入れ、起動時にはuvicornのログより先に「ブラウザで開くURLはこちら」のバナーを出すようにした。手順書のポートも-p 8000:8000に統一し、ログとURLの番号を一致させた。罠は塞ぐだけでなく、そもそも掘られないようにする。

ついでにもう1つ。「Cookieを消したのにログイン画面が出ない」という現象は、故障ではなかった。Cognito Hosted UIは自分のドメインにもセッションCookieを持っていて、ローカルのトークンを消しても無言で再ログインさせて戻してくる。ログイン画面を確実に出したければ、アプリのログアウトボタン(Cognitoの/logoutまで叩く正規手順)を使う。シークレットウィンドウで試して初めて「全部正常だった」と分かる類の話である。

次回

ローカルコンテナまで来たので、次はTerraformでECS Fargateに立てる。論点は洗い出してある: マイク(getUserMedia)はhttps必須なのでACM証明書とドメイン、CognitoのリダイレクトURI追加、履歴DBの置き場、ALBのidle_timeoutとWebSocket pingの関係。「terraform applyしたらサービスが立つ」まで、あと一歩だ。

リポジトリ(公開しました): github.com/yoshiharu-ishii/realtime_voice


リアルタイム音声AI 開発シリーズ

シリーズ目次(全7回のあらすじ)はこちら

コメントする

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

上部へスクロール