無言なのに AI が誰かと会話している原因は自分の残響だった
OpenAI の Realtime API で作っている音声実況 AI を、スピーカーから音を出す状態で無言テストしていました。誰も喋らない。カメラの前にも何もない。それなのに AI が誰かと会話を始めます。
入力側の転写ログを見ると、こう出ていました。
なよぎら。
そばランチ。言った覚えのない言葉が「ユーザーの発話」として転写されています。さらに、AI 自身がある単語を言った直後に、同じ単語が入力転写に現れていました。AI は自分の残響に返事をしていました。
実測: マイクの RMS は 0.001〜0.005 なのに拾われる
最初に疑ったのは音量です。ところがマイク入力の RMS は 0.001〜0.005 で、数値上は静寂です。ノイズゲートを入れても、この残留は止まりませんでした。
起きていたことを分解すると、燃料が 2 種類ありました。
- 残留エコー: スピーカーから出た AI の声がマイクに戻る。ブラウザの
echoCancellation: trueは効いているはずなのに、微小な残留がサーバー側の音声区間検出(VAD)に拾われる - 転写の幻聴: 無音に近い入力に対して、転写モデルが「ご視聴ありがとうございました」のような定型句を生成する。Whisper 系の転写で知られている挙動で、私の入力転写にもそれが出ていた
残留エコーが VAD を起こし、起きた区間の転写が幻聴を返し、AI がそれに答え、その声がまたマイクに戻る。このループが「誰かとの会話」の正体でした。
機構: 自前再生はブラウザの AEC の基準信号になりにくい
私の構成は、音声を PCM として WebSocket で受け取り、Web Audio API で自前再生するものでした。検証を速く回すには都合がよく、Gemini Live API のときも同じ構成で動かしていました。
ブラウザのエコーキャンセルは「自分が再生している音」を基準信号として、マイクからそれを引き算します。ところが Web Audio API で自前再生した音は、この基準信号として扱われにくい。結果として、スピーカーから出た AI の声は「環境のノイズ」としてではなく「外からの音」としてマイクに残ります。
ここで、ノイズゲートも VAD も無力です。どちらも「声かどうか」を見ていますが、AI の声も人間の声と同じ「声」だからです。問題の種類が「騒音」ではなく「自分の声」である限り、音量や声らしさで弾くことはできません。
どう直したか: 再生と収音を WebRTC に預ける
解は、再生と収音の両方を WebRTC に預けることでした。Realtime API は WebRTC 接続に対応しているので、音声の受信を WebSocket + 自前 PCM からWebRTC の音声トラックに切り替え、マイクも同じピア接続で送ります。
WebRTC の音声スタックは通話用に作られていて、再生している音を基準信号にしたエコーキャンセルが最初から組み込まれています。切り替えた後、無言テストで AI が独り言を始めることはなくなり、入力転写に AI 自身の台詞が混入することもなくなりました。
どこで止まるか
- 自前 PCM + WebSocket の構成が間違いだったわけではありません。イヤホンで検証している限りエコーは起きないので、開発中は最速の構成です。スピーカー運用の実機に出した時点で通話スタックに負ける、という順序でした
- WebRTC に預けると、再生タイミングや音声バッファの細かい制御はアプリから離れます。打ち切りの方法も
output_audio_buffer.clearに変わるので、自前再生を前提に書いた制御は書き直しになります - 転写の幻聴そのものは消えていません。残留エコーという入口を塞いだので、幻聴が起きる区間が発生しなくなっただけです
この構成で気をつけること
- 「声だけ認識する」対策(ノイズゲート、VAD の閾値)はエコーに効かない。問題を「騒音」と「自分の声」に分けて、後者には基準信号を持つエコーキャンセルを当てる
- Web Audio API の自前再生は、ブラウザのエコーキャンセルの基準信号になりにくい。スピーカー運用なら再生と収音を同じ通話スタック(WebRTC)に寄せる
- 無言テストは必ずスピーカーで行う。イヤホンでは、このループは再現しない
AI が誰かと会話しているように見えたのは、自分の残響と転写の幻聴が互いの燃料になっていたからでした。音量を下げても声を選別しても止まらないものは、エコーキャンセルの基準信号を持つ経路に預けるしかありませんでした。