#音声AI#WebRTC#エコーキャンセル#Pipecat

AIアバターが自分の声に返事し続ける — 対症療法1,657行を書いて、全部捨てるまで


音声で会話できるAIアバターを作っていると、いつか必ずこの光景を目にします。

アバターが挨拶する。スピーカーから出たその声をマイクが拾う。音声認識がそれを「ユーザーの発話」として文字起こしする。アバターが自分の挨拶に返事をする。その返事をまたマイクが拾う——アバターが一人で自問自答を始めるのです。

私たちはこの「自己エコー問題」と長いこと戦い、対症療法を積み上げ、最終的にそれを全部捨ててアーキテクチャごと作り直しました。フロントエンドから消えたコードは 1,657 行。この記事は、その顛末と、途中で踏んだ罠のほぼ全部です。

対症療法の沼

最初は素直な構成でした。ブラウザでマイクを開き、VAD(発話区間検出)で切り出してSTTへ送り、回答をTTSで合成してWeb Audioで再生する。全部ブラウザの中で完結します。

自己エコーが出たので、対症療法を足しました。ひとつずつは、どれも合理的に見えたのです。

  • エコー窓: アバターの発話後 N 秒間は、似た文字列の認識結果を捨てる
  • テキスト照合: 直近のアバター発話と bigram 一致率 0.75 以上なら「エコー」として破棄
  • ハルシネーション語彙リスト: 無音時にSTTが幻聴しがちな定型句を列挙して弾く
  • 重複質問破棄: 直近30秒以内の同一文言は再投入とみなして捨てる
  • 保留型バージイン: ユーザーが話し始めても即座に再生を止めない(エコーで自分の再生を止めてしまうから)。STTの確定を待ってから割り込む

これで自問自答は概ね止まりました。しかし、それぞれの対症療法が新しい問題を生みました

テキスト照合は「アバターが言ったことについて質問する」という最も自然な行動を破棄します(直前の発話と似ているから)。保留型バージインは割り込みを 2.1〜2.5秒 遅らせ、会話のテンポを壊します。エコー窓は本物の発話も呑み込みます。対症療法を調整するたびに別の何かが壊れ、「エコーは消えたが、本物の質問も消える」状態に近づいていきました。

転機: 対症療法は全部「1つの制約」の派生だった

あるとき、対症療法の一覧を眺めていて気づきました。これらは独立した工夫ではなく、1本の因果の鎖だということに。

自己エコーを音響的に消せない
  → ユーザー発話開始で即再生停止できない(自分の声で自分を止めてしまう)
    → STT確定を待つ保留型バージインになる(2秒超の遅延)
      → 確定したテキストがエコーか判定する必要が生まれる
        → テキスト照合・エコー窓・語彙リスト・重複破棄が要る
          → 本物の質問まで捨てるようになる

根本にあるのは1つだけ。**「スピーカーから出た自分の声を、マイク入力から音響的に消せない」**という制約です。

ここで疑問が湧きます。ブラウザには AEC(エコーキャンセラ)が標準搭載されているはずでは? getUserMedia({ audio: { echoCancellation: true } }) と書いていたのに、なぜ効かないのか。

答えは AEC の仕組みにありました。エコーキャンセラは「何を消すべきか」のお手本、つまり参照信号が必要です。ブラウザのAECが参照できるのは、基本的にブラウザ自身の音声再生経路<audio> 要素やWebRTCの受信トラック)です。ところが私たちはTTS音声をWeb Audio APIのグラフで自前再生していて、この経路はAECの参照として確実には機能していませんでした。AECは「消すべき音」を知らないまま、ただ座っていたのです。

根治: 音声パイプラインをサーバへ、音はWebRTCで返す

制約が特定できれば、設計は決まります。

  1. VAD・STT・ターン制御・TTSをすべてサーバ側の1本のパイプラインに移すPipecat を採用)
  2. ブラウザはマイクをWebRTCで送るだけ
  3. アバター音声は同じWebRTCセッションのリモートトラックとして返し、<audio> 要素で再生する

ポイントは3です。リモートトラックの再生はブラウザAECの正式な参照信号になります。つまり「アバターの声」をブラウザが消すべき音として知っている状態になる。

結果は劇的でした。

  • スピーカー音量を上げたままアバターが99秒連続で発話しても、誤検出されたユーザーターンはゼロ。テキスト照合もエコー窓も語彙リストも、ひとつも使わずに
  • バージイン(発話への割り込み)は 2.1〜2.5秒 → 4〜6ミリ秒
  • 対症療法の連鎖は、根本の制約が消えた瞬間、全部不要になった

……と、ここで終われば美談なのですが、本番はここからでした。移行は罠の連続だったのです。以下、踏んだ順にすべて書きます。

罠1: AECはタダではない — 二重発話でユーザーの声が歪む

新経路に切り替えてすぐ、ユーザーから「聞き間違いが増えた気がする」と報告が来ました。「社員数」が「シャインズ」になる。

調べると、機構はこうでした。旧経路のAECは実質無効だったので、ユーザーの声は混ざるが無歪みでした。新経路のAECはエコーを消す代わりに、アバターと同時に喋った瞬間のユーザーの声(特に語頭)を歪ませます。二重発話時の近端抑圧という、AECの原理的なトレードオフです。「自己エコーゼロ」の対価は無料ではなかった。

対策は3層にしました。

  1. 音の入口: マイク音声のOpusビットレートを128kbpsに指定し、インバンドFECを有効化(answer SDPのfmtp行を書き換える)。旧経路は無圧縮PCMを送っていたので、情報量の差を詰める
  2. STT: faster-whisperの initial_prompt に語彙ヒントを渡す。ここで重要なのは辞書を作らないこと。企業ごとの固有名詞辞書は際限なく膨らみます。代わりに「直近のアバター発話テキスト」をそのまま渡す——台本や回答には画面上の固有名詞が正しい表記で既に含まれているので、登録作業ゼロでテナントごとの語彙が自動的に効きます。ユーザー側のSTT結果は絶対に混ぜない(一度の誤認識がヒントに入って固定化するため)。長さは160字まで(長いとwhisperがプロンプト文を「聞いた」ことにする副作用がある)
  3. LLM: システムプロンプトに「入力は音声認識の書き起こしであり、音の近い誤変換を含み得る。文脈で不自然な語は音の近い語として解釈し、冒頭で『◯◯のご質問ですね』と確認する。近い語が見つからない造語は説明を創作せず聞き返す」という指示を追加

3層目が意外なほど効きます。人間が乱れたチャット入力を文脈で読めるのと同じことを、LLMは音声の誤変換に対してもできる。ただし「創作しない」の一文がないと、LLMは知らない単語「シャインズ」の製品説明を堂々と捏造します。

罠2: 「反応しない」と「聞こえていない」は別の障害

「プレゼン開始直後、話しかけても反応しない時間がある」という報告。サーバログを見ると、その時間帯のマイク入力は完全な無音でした。ユーザーの声が「無視されている」のではなく「届いていない」。

ここで役に立ったのが、相槌タイミング推定用にパイプラインへ入れていたモデル(マイク音声を10Hzで常時観測している)のログでした。反応確率が 0.000 で一直線——つまり音響エネルギー自体が来ていない。犯人は複数いました。

  • マイクトラックが enabled=false で開始する設計だった(マイクボタンを押すまで無音を送信し続ける)。ユーザーは「いつでも話しかけられる」つもりでいる。設計の約束と実装の約束がズレていた → 準備完了と同時に自動ONへ変更
  • AEC/AGCの収束時間。エコーキャンセラは「アバターの音が実際に鳴っている時間」でしか学習できず、セッション最初の発話中は未収束で、二重発話の声を丸ごと呑む。→ 登場演出の間にアバターへ短い挨拶を喋らせて、本編前にAECへ教材を与える。ついでに autoGainControl はオフに(ゲインの立ち上がりで序盤の声が小さく扱われる。STTはサーバ側whisperなので音量変動には強い)
  • 切り分けを速くするため、実際に送信しているストリームのレベルメーターをUIに追加。「マイクOFF/前処理に食われている/サーバ側」がその場で見分けられる

教訓: 音声系の「反応しない」は、どこで音が死んでいるかを観測できる点を持っていないと、推測合戦になる。10Hzの入力観測ログとレベルメーターは、その後の調査を桁違いに速くしました。

罠3: ヘッドレス環境の三連撃(配信レンダラー編)

このアバターはYouTube/Twitchへのライブ配信もします。配信はクラウドGPU上のヘッドレスChromiumがアバターページを開き、canvasと音声をffmpegでRTMPに流す構成。ここに新経路を通したら、3つの罠が連続で待っていました。

その1: ヘッドレスにマイクは無い。 接続処理の冒頭で getUserMedia を呼んでいたため、マイクの無いレンダラーでは例外で接続自体が死に、配信が無音のまま流れました(映像は無音キープアライブで生きてしまうのがまた質が悪い)。→ 配信ページはWebAudioの createMediaStreamDestination無音の合成トラックを作って送る。SDP契約はマイクと同一なのでサーバは無改修。

その2: 入力の無い destination はフレーム生成を止めることがある。 無音トラックのRTPが途絶するとサーバ側の音声読み取りがエラーになり切断される。→ 値0の ConstantSource を繋いで「実サンプルとしての無音」を流し続ける。

その3(最大の罠): Chromiumは、WebRTCのリモートMediaStreamをメディア要素が消費するまでWebAudioに流さない。 配信音声はアバターのリモートトラックを createMediaStreamSource でキャプチャ用タップに合流させる設計でしたが、これだけでは無音が流れます。通常ページで音が出ていたのは、AEC参照のために <audio> 再生を最初からしていたから。配信ページだけこの一手が欠けていました。→ muted<audio>srcObject を挿して play() するだけ(音は出さない、再生を「起こす」ことが目的)。

この既知挙動、知っていれば1行の修正ですが、知らないと「サーバは発話している・接続も生きている・でも配信は無音」という気持ちのいい詰み方をします。

罠4: フレームワークの善意に殺される — アイドルタイムアウト

配信で「挨拶とチャット応答のあと、9分間無言。終了の挨拶もしない」という事象。ログを遡ると犯人は Pipecat の**アイドルタイムアウト(既定300秒)**でした。

対話用途では「5分間何も起きないパイプラインは放置セッションだから回収する」は正しいデフォルトです。しかし配信はマイクが恒久無音で、トピックが無ければ正当に沈黙する。最後の発話から5分後、パイプラインは Idle pipeline detected, cancelling と言い残して自殺し、その後に届いた終了挨拶の指示も視聴者のチャットも、喋る主体がいないまま虚空に消えていました。

→ 配信ルートのみ cancel_on_idle_timeout=False。対話ルートは既定のまま(放置セッション回収として有効)。

フレームワークの「気の利いた既定値」は、ユースケースが変わると牙を剥きます。ワークロードごとに既定値の前提を棚卸しする価値があります。

罠5: 課金は自分で見張る

クラウドGPUの従量課金で配信検証をしていると、もう1つの学びがあります。アバター接続が失敗した配信は「開始扱い」にならず、尺ベースの自動終了が発火しないケースがありました(幸い25分の安全網タイムアウトが別途あり、無限課金にはならない設計でしたが、それを知るまでは肝が冷えました)。

以後の検証では、**「検証が終わるたびにクラウド側のインスタンス一覧がゼロであることをAPIで確認する」**を必須手順にし、さらに検証セッション自体に「N分後に残インスタンスを強制チェックして、残っていれば殺す」という自前の安全網タイマーを仕掛けました。従量課金インフラの検証は、フレームワークの後始末を信用しつつ、最後の確認だけは自分の手でやるのが精神衛生にいい。

罠6: テストハーネスは「実装」ではなく「実機ログ」から書く

移行検証の往復を減らすため、ブラウザ無しで実際にWebRTC接続し、録音済みWAVを等速投入してメッセージ契約を機械判定するE2Eハーネスを作りました(4シナリオ24項目)。これは大成功で、「メッセージが届かない・順序が違う」系の不具合はほぼ全部ここで捕まえられるようになりました。人間に頼むのは自己エコー・声の自然さ・リップシンクの目視だけ。

ただし一度、手痛い失敗をしています。「続けて」と言うと説明を再開する機能のテストを自分の実装に合わせたシナリオで書いてしまい、ハーネスは緑なのに実機では直っていなかった。原因は、一時停止が「停止ボタン」だけでなく「質問」でも入るという実機の流れを、シナリオが再現していなかったこと。以後、シナリオは実機ログに残った実際の発話列から書くをルールにしました。

もうひとつ、偽の退行にも注意。TTSモデルのプリウォームがGPUを占有している時間帯にハーネスを回すと、STTが数十秒待たされて不合格になります。デプロイ直後の「壊れた!」は、まず環境要因(ウォームアップ・クォータ・並行ビルド)を疑う。

撤収: 1,657行の削除

新経路が5つの出力先(管理画面・埋め込みウィジェット・デスクトップアプリ・プレゼン・ライブ配信)すべてで検証を通ったあと、旧経路と対症療法を削除しました。

  • ブラウザ側VAD/STT一式
  • エコーガード、エコー窓、ハルシネーション語彙、重複破棄、保留型バージイン
  • ブラウザ側の再生キューと、それに吊られていた字幕・表情・ページ遷移の同期機構
  • WS再接続・ハートビート機構(WebRTC側に一本化)

フロントエンドだけで -1,657行。主要コンポーネントは 957→639行、500→363行、487→367行になりました。

削除時にも罠があります。旧経路の作法は新経路では原理的に成立しないものが多い。例えば旧経路の stopAudio() はリップシンク切断を含み、再生キューが毎回再接続するから成立していました。新経路には再接続する人がいないので、同じ呼び出しが「以後ずっと口が動かない」バグになる。移植・削除のときは「この呼び出しは何を前提にしているか」を1つずつ問う必要がありました。

まとめ: 対症療法が増え続けたら、根の制約を探す

この一件で持ち帰った教訓を並べます。

  1. 対症療法が3つを超えたら、それらが1つの制約の派生でないか図を描いて確かめる。派生の連鎖なら、いくら枝を刈っても増え続ける。根を抜くと全部一度に消える
  2. ブラウザのAECは「参照信号」が命。自前のWeb Audio再生はAECのお手本にならないことがある。WebRTCのリモートトラック+<audio>再生は正式な参照になる
  3. AECは二重発話でユーザーの声を歪ませる。消した問題の対価はどこかで払う。STTの語彙ヒントは辞書ではなく「直近の自分の発話」が登録コストゼロで効く
  4. 「反応しない」と「聞こえていない」を区別できる観測点(入力レベルの常時ログ・送信ストリームのメーター)を最初に作る
  5. ヘッドレス環境は別世界。マイクは無い、無入力ノードは止まる、リモートストリームは消費者がいないとWebAudioに流れない
  6. フレームワークの既定値はユースケースが変わると凶器(アイドルタイムアウト)
  7. 従量課金の検証は、終了確認を自分の手で
  8. テストシナリオは実装ではなく実機ログから書く。機械判定できるものは全部ハーネスに寄せ、人間には耳と目だけを頼む

音声対話AIは「動くデモ」までは簡単で、「会話として成立する製品」までの距離が異様に長い分野です。その距離の大半は、この記事に書いたような音の物理とブラウザの現実でできています。同じ沼にいる誰かの、ショートカットになれば幸いです。

← 記事一覧へ