ブラウザ音声対話AIのハマりどころ図鑑 2026 — AEC・getUserMedia・ヘッドレスの罠16連発
ブラウザで音声対話AI(アバター・ボイスボット・配信AI)を作ると、音の物理とブラウザの実装事情に由来する罠に次々ぶつかります。本記事は、実際に製品開発で踏んだ罠を**「症状 → 原因 → 対処」の逆引き形式**で16個まとめたものです。上から読む必要はありません。いま出ている症状の見出しに飛んでください。
エコー・自己応答系
1. アバターが自分の声に返事する(echoCancellation: true なのに)
- 症状: TTS音声をマイクが拾い、STTがユーザー発話として認識。自問自答ループ
- 原因: AEC(エコーキャンセラ)は「消すべき音のお手本=参照信号」が必要。参照になるのはブラウザの正式な再生経路(
<audio>/ WebRTC受信トラック)で、Web Audio APIによる自前再生は参照として確実には機能しない - 対処: TTS音声をサーバからWebRTCのリモートトラックとして返し、
<audio>要素で再生する。これだけで、テキスト照合等の対症療法なしにエコーが消える(実測: スピーカー音量ありで99秒連続発話してもユーザーターン誤検出ゼロ)
2. エコーは消えたが、アバターと同時に喋ると自分の声が歪んで誤認識される
- 症状: 二重発話時だけ固有名詞が化ける(「社員数」→「シャインズ」)。特に語頭
- 原因: AECの原理的トレードオフ。エコーを消す代償として、二重発話中の近端(ユーザー)音声を抑圧・歪曲する
- 対処: 3層で緩和。①マイクのOpusビットレートを上げFECを有効化(罠13)②STTに語彙ヒント(後述の別記事参照: 辞書でなく「直近のアバター発話」を initial_prompt へ)③LLMに「入力はSTT書き起こしで誤変換を含む。文脈で不自然な語は音の近い語として解釈し、確認を添える」と指示
3. 他のアプリが再生する音楽・動画の音は消せない
- 症状: エージェントに開かせたYouTubeの音声・歌詞がSTTに入り続ける
- 原因: ブラウザのAECが参照できるのは自分(同一タブ/アプリ)が再生した音だけ。他プロセスの音はマイクにとって人間の声と同じ空気の振動
- 対処: 技術で完全解決は不可。OSの話者分離(macOS「声を分離」等。
voiceIsolation: true制約でコードから要求も可能、非対応環境では無視される)、ヘッドホン、後段(LLM/ツール判断)でのノイズ入力棄却を併用
「序盤だけ聞こえない」系
4. セッション開始直後の発話だけ認識されない
- 症状: 最初の10〜20秒だけ話しかけが無反応。以降は正常
- 原因: 複合。(a) AECの収束——エコーキャンセラは「相手の音が実際に鳴っている時間」でしか学習できず、未収束の間の二重発話はユーザーの声ごと抑圧する。(b) autoGainControl の立ち上がり——ゲインが徐々に上がるため序盤の声が小さく扱われる
- 対処: (a) 本編開始前に短い挨拶や効果音を鳴らしてAECに教材を与える(ローディング演出中がちょうどいい)。(b)
autoGainControl: false。STTがサーバ側なら音量変動には強く、AGCを切る不利益はほぼない。echoCancellation は絶対に切らない
5. 「反応しない」の切り分けができない
- 症状: マイクOFFなのか、前処理に食われたのか、サーバ側なのか分からず推測合戦になる
- 対処: 観測点を2つ作る。①サーバ側で入力音声のエネルギー/確率を常時ログ(10Hz程度。「完全無音」と「歪んだ音声」を区別できる)②UIに実際に送信しているストリームのレベルメーター(
createMediaStreamSourceで送信と同じストリームを読むだけ。送信に影響しない)
6. マイクトラックを enabled=false で始めたら「話しかけても無反応」とクレーム
- 症状: 実装は「ボタンを押すまでマイクOFF」の仕様どおり。しかしユーザーは「いつでも話しかけられる」つもり
- 原因: バグではなく設計の約束と体験の約束のズレ。無音を送信し続けるトラックは、サーバから見ると「ずっと静かな部屋」と区別がつかない
- 対処: プロダクトの約束に合わせて既定を決める。「いつでも話しかけられる」が売りなら初期化完了と同時に自動ON(接続と同時のONは初期化中に会話が始まって壊れるので、準備完了イベントで)。ボタンはミュートとして残す
Chromium実装の罠
7. リモート音声を WebAudio に繋いだのに無音(リップシンクも動かない)
- 症状: WebRTCの受信ストリームを
createMediaStreamSourceで解析/加工グラフに接続したが、データが流れてこない - 原因: Chromiumは、リモートMediaStreamをメディア要素が消費し始めるまでWebAudioに流さない(長年の既知挙動)
- 対処:
mutedの<audio>にsrcObjectを挿してplay()する。音は出さず「再生を起こす」ことだけが目的
const a = new Audio();
a.muted = true;
a.srcObject = remoteStream;
a.play().catch(() => {});
audioElRef.current = a; // GC対策に参照保持
8. MediaStreamAudioSourceNode が黙って死ぬ
- 症状: しばらく動いた後、解析グラフへの入力が消える。エラーなし
- 原因: Chromeは参照されていないノードをGCすることがあり、入力が静かに止まる
- 対処: ノード(と上記の audio 要素)を ref 等で必ず保持する
9. 音量スライダーが効かない(新経路だけ)
- 症状: GainNodeで音量を作っていたのに、リモートトラック再生に切り替えたら無反応
- 原因:
<audio>再生はWeb AudioのGainを通らない - 対処:
audioElement.volume/.mutedを操作する。両経路が併存する期間は両方に適用
ヘッドレス環境(配信・自動化)の罠
10. ヘッドレスChromiumで接続が入口から死ぬ
- 症状: サーバに接続要求(offer)が一度も届かない
- 原因: 接続処理の冒頭の
getUserMediaが、マイクの無いヘッドレス環境で例外を投げる - 対処: マイク不要のユースケース(配信レンダラー等)はWebAudioで合成した無音トラックを送る。SDPもサーバ側パイプラインも実マイクと同一契約で動く
const ctx = new AudioContext();
const dest = ctx.createMediaStreamDestination();
const keep = ctx.createConstantSource();
keep.offset.value = 0; // 「実サンプルとしての無音」を流し続ける(罠11)
keep.connect(dest);
keep.start();
pc.addTrack(dest.stream.getAudioTracks()[0], dest.stream);
11. 入力の無い MediaStreamAudioDestinationNode は止まることがある
- 症状: 無音トラックで接続したのに、しばらくするとRTPが途絶して切断される。MediaRecorderに繋いだ場合はmuxerごと停止して映像も止まる
- 原因: 入力ソースの無いdestinationはフレーム生成を止める実装がある
- 対処: 上記のとおり
ConstantSource(0)を接続してアクティブにレンダリングさせる
12. autoplayポリシーで再生・AudioContextが始まらない
- 症状: ヘッドレスでは音が出ない/ctxがsuspendedのまま
- 対処: 起動フラグ
--autoplay-policy=no-user-gesture-required+ctx.resume()。通常ブラウザではユーザー操作起点でresume()する「アンロック」を必ず挟む
品質・チューニングの罠
13. WebRTCマイク音声の既定ビットレートは意外と低い
- 症状: WS+生PCM送信からWebRTC(Opus)に替えたらSTT精度が下がった
- 原因: Opusの既定は実質30kbps前後。非可逆圧縮の情報量差が、二重発話などの限界条件で効いてくる
- 対処: answer SDPのfmtp行を書き換えて
maxaveragebitrate=128000;useinbandfec=1を指定(answer側のfmtpが送信側エンコーダを律する)。FECはTURN経由のパケット損失の穴埋めにも効く
14. VADの沈黙待ちが応答遅延の支配項
- 症状: 応答が体感2秒近く遅い。プロファイルするとLLMでもTTSでもない
- 原因: 「発話が終わった」と判定するための沈黙待ち(stop_secs等)が1秒以上を占める。ここは短くすると言い淀みで文が切れる(別の事故になる)トレードオフ
- 対処: 銀の弾丸なし。STTを発話中に並行実行して確定を先取りする、ターン検出モデル(smart turn系)を併用する、で削る。バージインの速さ(割り込み即停止)が体感を大きく左右するので、そちらを先に確保する
15. デプロイ直後だけSTT/TTSが遅い・失敗する「偽の退行」
- 症状: リリース直後のテストが不合格。コードを疑って時間を溶かす
- 原因: モデルの遅延ロード・プリウォーム(TTS話者モデルの直列ロード等)がGPU/イベントループを占有し、推論APIが数十秒待たされる
- 対処: プリウォーム完了を待ってから検証する(「ロード開始」ログ数と「完了」ログ数の一致を見る等)。E2Eハーネスにウォームアップ済み確認を組み込む
16. サーバ側パイプラインのアイドルタイムアウト
- 症状: 長い沈黙が正当なユースケース(配信・見守り等)で、5分後にセッションが勝手に死ぬ
- 原因: パイプラインフレームワークの既定(例: Pipecatの idle timeout 300秒)は対話用途前提。「5分無活動=放置」とみなして自己キャンセルする
- 対処: ワークロード別に設定を分ける(配信ルートのみ
cancel_on_idle_timeout=False等)。フレームワークの気の利いた既定値は、ユースケースが変わると凶器になる前提で棚卸しする
チェックリスト(保存用)
- TTS再生はWebRTCリモートトラック+
<audio>(AECの参照になる経路)か - AECの収束前に「教材」となる音を鳴らしているか
-
autoGainControlを切る判断をしたか /echoCancellationは残っているか - 入力エネルギーの常時ログと送信レベルメーターがあるか
- リモートストリームは muted
<audio>で消費してからWebAudioに繋いでいるか - ノード・要素の参照を保持しているか(GC対策)
- ヘッドレス経路は合成無音トラック+ConstantSource(0)か
- Opusのビットレート/FECを指定したか
- プリウォーム完了を待ってから検証しているか
- アイドルタイムアウト等の既定値をワークロード別に見直したか
同じ沼を泳ぐ方の時間が、1時間でも節約できれば幸いです。