#WebRTC#WebAudio#音声AI#getUserMedia

ブラウザ音声対話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時間でも節約できれば幸いです。

← 記事一覧へ