サブ500msのレイテンシで音声AIを作った。誰も語らないエコーキャンセレーション問題

Dev.to / 2026/4/5

💬 オピニオンDeveloper Stack & InfrastructureIdeas & Deep AnalysisTools & Practical Usage

要点

  • 著者は、STT→LLM→TTSというテキストパイプラインを避け、Gemini 2.5 Flash Live APIを使って、真の音声から音声へのボイスAIシステムであるGoNoGo.teamを構築した。
  • 最大のエンジニアリング課題は、多エージェントの推論やオーケストレーションではなく、AIがエコーキャンセレーションによって「自分の声を聞いてしまう」ことを防ぎ、自己の発話を中断しないようにする点だった。
  • 本システムはテキストバッファで処理するのではなく、WebSocketsで生のPCMオーディオをストリーミングする(マイク入力は16kHz、エージェント出力は24kHz)。そのため、クライアント側の音声処理とレイテンシ管理が中核になる。
  • 実装上の具体例として、ブラウザのマイク音声を約32msのフレームに分割(例:512サンプルのチャンク)し、VADにはRMSベースの解析を用いて、音声をどのタイミングで上流へ送るかを制御する。

私がGoNoGo.teamを作り始めたとき――AIエージェントが音声で創業者にインタビューして、スタートアップのアイデアを検証するためのプラットフォームです――難しいのはAIの推論部分だと思っていました。マルチエージェントのオーケストレーション。40以上のファンクションコーリングツール。

でも、間違っていました。

難しかったのはエコー(こだま)です。具体的には、AIエージェントが自分の話し声を聞いてしまってパニックになり、自分の文を途中で中断してしまうのを、どうやって止めるのか?

500回以上の音声セッションと、RMS波形をにらみ続けた夜更かしの数えきれないほどの時間のあとで、私が実際に学んだことをまとめます。

セットアップ:STT → LLM → TTSではなく、Speech-to-Speech

GoNoGoはGemini 2.5 Flash Live APIで動いています。これは本物のspeech-to-speechパイプラインです。途中に文字起こし(トランスクリプション)のステップはありませんし、後からくっつけるテキスト読み上げ(TTS)の合成レイヤーもありません。音声を入力して音声が出力されます。ストレートです。

これは、クライアント側で音声を扱う方法のすべてを変えるので重要です。テキストバッファを扱っているわけではありません。ブラウザのマイクからの16kHz入力と、エージェントの音声からの24kHz出力という、生のPCMを扱います。それをWebSocket経由でBase64にエンコードします。

ブラウザ側のキャプチャは、おおむね次のようになります:

// ブラウザのScriptProcessorNode — 512サンプルのチャンク(約32msごと)
const scriptProcessor = audioContext.createScriptProcessor(512, 1, 1);

scriptProcessor.onaudioprocess = (event) => {
  const inputBuffer = event.inputBuffer.getChannelData(0);

  // VAD用にRMSを計算
  const rms = Math.sqrt(
    inputBuffer.reduce((sum, sample) => sum + sample * sample, 0) / inputBuffer.length
  );

  // VADのしきい値:0.05 RMS
  if (rms < VAD_THRESHOLD) return;

  // Float32 PCMをInt16へ変換
  const int16Buffer = new Int16Array(inputBuffer.length);
  for (let i = 0; i < inputBuffer.length; i++) {
    int16Buffer[i] = Math.max(-32768, Math.min(32767, inputBuffer[i] * 32768));
  }

  // Base64エンコードしてWebSocketで送信
  const base64Audio = btoa(String.fromCharCode(...new Uint8Array(int16Buffer.buffer)));
  ws.send(JSON.stringify({ type: 'audio_chunk', data: base64Audio }));
};

シンプルです。AIが話し始めるまでは。

エコー問題(そしてなぜブラウザのAECでは不十分なのか)

ブラウザには、内蔵の音響エコーキャンセル(acoustic echo cancellation)があります。これはgetUserMediaを呼び出すときに有効化します:

const stream = await navigator.mediaDevices.getUserMedia({
  audio: {
    echoCancellation: true,
    noiseSuppression: true,
    autoGainControl: true
  }
});

これは人間同士のビデオ通話に対してはとても良く機能します。そういう用途のために設計されています。ですが、根本的な前提が1つ埋め込まれています。つまり「遠端側」の音声が、ブラウザが把握している <audio>要素、またはWeb Audio APIを通じて流れてくる、という前提です。

WebSocketから受け取った24kHzのPCMチャンクを、手動でデコードしてAudioContextのバッファにスケジュールして再生しているとしたら? ブラウザのAECは、その音声が存在することを知りません。見えていないものは打ち消せません。

AIエージェントが話し始めます。マイクがスピーカーの出力を拾います。エージェントは自分自身の声を聞きます。最良の場合、混乱して何かを繰り返します。しかし最悪の場合――そしてこれは初期のビルドで頻繁に起きました――フィードバックループになり、エージェントが文の途中で自分を遮ってしまい、その遮断を聞き取り、それに応答しようとして、その結果を聞き取り、セッション全体が崩壊します。

私はこれを1011切断(disconnects)と呼びました。ログで繰り返し見かけていたのが、そのWebSocketのクローズコードだったからです。

RMSゲートを二段にする

オーディオキャプチャ側に、二段階のRMS(Root Mean Square)ゲートを入れるのが解決策です。考え方はシンプルです。マイクが拾っている音量(ラウドネス)を測り、それがたぶんスピーカーの再生によるものなら送らない、というだけです。

ですが、「シンプル」は多くの例外ケースを隠しています。

Tier 1: エージェントの発話中は強制サプレッション

エージェントが話している間、私はサーバー側でその状態を追跡し、クライアントに送ります。このウィンドウでは、受信オーディオは完全に抑制されます――Geminiにチャンクを送信しません。

let agentSpeaking = false;
let cooldownTimer: ReturnType<typeof setTimeout> | null = null;
const COOLDOWN_MS = 1500;
const COOLDOWN_THRESHOLD = 0.03; // クールダウン中は閾値を高めに
const NORMAL_THRESHOLD = 0.05;   // 通常のVAD閾値

// エージェントの音声ストリームが開始/停止したときに呼ばれる
function setAgentSpeakingState(speaking: boolean) {
  if (speaking) {
    agentSpeaking = true;
    if (cooldownTimer) clearTimeout(cooldownTimer);
  } else {
    agentSpeaking = false;
    // クールダウン期間を開始
    cooldownTimer = setTimeout(() => {
      cooldownTimer = null;
    }, COOLDOWN_MS);
  }
}

function shouldSendAudioChunk(rms: number): boolean {
  if (agentSpeaking) return false; // 強制サプレッション

  if (cooldownTimer !== null) {
    // クールダウン中: 閾値を高く
    return rms > COOLDOWN_THRESHOLD;
  }

  return rms > NORMAL_THRESHOLD;
}

Tier 2: 1.5秒のクールダウン

ここが一番理解するのに時間がかかりました。エージェントが話すのをやめると、部屋のスピーカー共鳴はまだ残っています。キャプチャした音声のRMSは、すぐにはゼロに落ちません――減衰していきます。典型的な在宅オフィスのバックグラウンドノイズは、だいたい0.01〜0.02 RMSです。しかし再生が止まってから1〜2秒の間は、0.025〜0.04 RMSが見えており、通常のVAD閾値を上回ります。

クールダウン期間では、エージェントの発話終了後1.5秒間だけ、より高い閾値(0.03対0.05)を使います。これにより、すぐに返事をし始める相手を切り落とさずに、減衰を捕まえられます。

この閾値は経験的に調整しましたか? もちろんです。私は何日もセッションのリプレイを聞き、さまざまなマイク設定で部屋の共鳴がどれくらいの速さで減衰するかを、正確に測定しながら作業しました。

セッション再開:問題のもう半分

エコーキャンセレーションは品質の問題を解決しました。セッション再開は信頼性の問題を解決しました。

Gemini Liveのセッションは落ちます。ネットワークの小さな不具合、モバイルのハンドオフ、Chromeがメモリに対して強引な何かをしようと決める――などで、接続が失敗します。初期の頃は、接続が落ちると、30分の面談を最初からやり直すことになっていました。創業者たちは激怒してすぐに辞めてしまうこともありました。私はその気持ちを完全に理解できました。

解決策:Firestoreにセッションハンドルを保存し、再接続時に再開する。

# FastAPIバックエンド—セッション管理
from google.genai.live import AsyncSession
from firebase_admin import firestore

返却形式: {"translated": "翻訳されたHTML"}async def get_or_create_session(
    project_id: str, 
    user_id: str
) -> tuple[AsyncSession, bool]:
    db = firestore.client()
    session_ref = db.collection('sessions').document(f'{user_id}_{project_id}')
    session_doc = session_ref.get()

    if session_doc.exists:
        session_data = session_doc.to_dict()
        handle = session_data.get('resumption_handle')

        if handle:
            try:
                # 再開を試みる――Gemini は中断したところから正確に引き継ぎます
                session = await resume_gemini_session(handle)
                return session, True  # resumed=True
            except Exception:
                pass  # 新しいセッションへフォールスルー
    # 新しいセッションを作成
    session = await create_gemini_session(project_id)
    session_ref.set({
        'created_at': firestore.SERVER_TIMESTAMP,
        'project_id': project_id
    })
    return session, False  # resumed=False
async def store_resumption_handle(user_id: str, project_id: str, handle: str):
    db = firestore.client()
    session_ref = db.collection('sessions').document(f'{user_id}_{project_id}')
    session_ref.update({'resumption_handle': handle})

セッションが再開されると、Gemini は完全なコンテキストを復元します――すべてのツール呼び出し結果、すべての市場調査の内容、統合されたフォーカスグループ内のすべてのペルソナです。創業者はつながり直し、エージェントは「ごめん、ところでどこまで話してたっけ?」と言い、あなたがどこにいたかを本当に把握しています。

The Filler Audio Problem

もう1つ、誰も話さないことがあります。AIが考えている間、何を流すのか?

Gemini 2.5 Flash は速いです。エンドツーエンドで300〜500msというのは本当に速い。でも、エージェントがツール呼び出しを実行しているとき――Playwrightで競合サイトをクロールしたり、Redditのスクレイピングを実行したり、ユニットエコノミクスを計算したりすると――3〜8秒のギャップが発生することがあります。

音声会話での無音は、壊れているように感じられます。ユーザーは回線が切れたと思い込みます。

解決策:事前に計算しておいたフィラー音声です。「少々お待ちください」や「確認してきます」といった短いフレーズを17言語で用意し、PCMチャンクとして保存して、ツール実行が約800msを超えたときに再生します。エージェントはテキスト信号で起動します(proactive_audioではありません。これは回帰によって二重再生が発生したため、完全に無効化しています。その代わりテキストトリガーを使います)。

これは些細な話に聞こえるかもしれません。でも、「アプリが壊れている」というサポートメッセージを約40%減らしました。

What I'd Do Differently

  1. AIのロジックからではなく、エコーゲートから始める。 デモを確実に動かせるようになる前に、美しいマルチエージェントのオーケストレーションを何週間も作っていました。順番が間違っていました。

  2. 初日からRMS値を計測する。 ログを取ります。すべてのセッションで。見えないものはチューニングできません。

  3. 動作確認は悪いハードウェアで。 開発環境は、スピーカーから物理的距離がある良いマイクを使っています。ほとんどのユーザーは、ノートPCのスピーカーの30cm近くにノートPCのマイクがあります。その前提で作るべきです。

  4. モバイルは別世界。 iOS Safari は AudioContext のライフサイクルを、あなたのキャリア選択を疑わせるような形で扱います。ただしそれは別の記事にします。

The Result

これらの問題を解決した後――2段階のRMSゲート、1.5秒のクールダウン、セッション再開、フィラー音声――GoNoGo は 15〜45分のボイスセッションを、実在する創業者相手に、21言語で実行できます。しかも会話の途中で、3つのAIエージェントが互いに引き継いでいきます。「1011」の切断は、実質的に消えました。

音声インフラは見えなくなりました。あるべき姿です。

ブラウザのマイク+リアルタイムAI音声で何かを作っているなら:これまでで一番の課題は何でしたか?エコーの問題が普遍的なものなのか、それとも最初の段階で私が特に何か間違ったことをしていたのか、正直とても気になります。コメント欄に書いてください。