Gemini Live API + DeepgramでリアルタイムAI音声会話をする

GeminiのLive API は音声をそのまま受け取って会話できて便利なんですが、日本語で使っているとinputTranscription が長時間発話で不安定になる問題がありました。

ユーザー側のターンで発話での応答が遅延・欠落してしまう問題が。。。

inputTranscriptionを結合してみてみるとなんか、発話時間?が一定を超えたらめちゃくちゃバグってる。

抜粋 生産、生産、生産、生産、生産、生産、生産、SEEM、SEEM、SEEM、SEEM、SEEM、SE、SE、SE、SE、SE、SE、SEEM、SEEM、SEEM、SEEM、SEEM、ふ、ふ、ふ、ふ、ふ、ふ、ふ、ふ、ふ、ふ、ふ、ふ、ふ、ふ、ふ、ふ、ふ、ふ、ふ、ふ、ふ、ふ、ふ、ふ、あ、あ、あ、あ、あ、あ、あ、あ、あ、あ、あ、あ、あ、あ、あ、あ、あ、あ、あ、あ、あ、そう、そう、そう、そう、そう、そう、そう、そう、そう、そう、そう、そう、そう、うん

zenn.dev

そこで音声認識(STT)を Deepgram に分離し、Live API にはテキストだけを渡す構成にして安定化を図ることにしました。

マイク → AudioWorklet (16kHz PCM) → Deepgram STT (WebSocket)
                                          ↓ テキスト
                                    Gemini Live API (テキスト入力のみ)
                                          ↓ 音声応答 (24kHz PCM)
                                    Web Audio API → スピーカー

音声は Deepgram にだけ流し、Live API には sendClientContent() でテキストだけを送ります。よくあるSTT→LLM→TTSのLLM→TTSの部分が一体になってるイメージです。

技術 役割
STT Deepgram nova-3 日本語音声 → テキスト変換
AI会話 + TTS Gemini Live API テキスト入力 → 音声応答生成
音声処理 Web Audio API + AudioWorklet 入出力の PCM 変換・再生

本当はSTTはVADとかASRとか色々考えないといけないんですが、いい感じに抽象化して一般消費者にも扱いやすいお値段で提供してくれているので時代に感謝ですね(?)

参考 engineers.ntt.com

15分のセッション1回あたりの概算はこんな感じです。

サービス 単価 15分あたり
Deepgram nova-3 (streaming, multilingual) $0.0092/min 約 $0.14
Gemini 2.5 Flash Native Audio (audio output) $12.00/1M tokens32 tokens/sec 約 $0.17〜$0.35 ※
合計 約 $0.31〜$0.49/回

※ Gemini の音声出力コストは AI の発話時間に依存します。15分のセッション中 AI が半分話す想定で約 $0.17、ほぼずっと話す想定で約 $0.35 です(32 tokens/sec × 60sec × 分数 × $12.00/1M tokens)。テキスト入力分($0.50/1M tokens)は少量のため省略。


Deepgram で日本語 STT

ブラウザからの接続にはサーバー側で一時トークン(TTL 1200秒 = セッション15分 + バッファ5分)を発行しています。クライアント側の接続設定はこんな感じです。

const connection = deepgram.listen.live({
  model: "nova-3",
  language: "ja",
  smart_format: true,
  punctuate: true,
  interim_results: true,
  endpointing: 1500,       // 1.5秒の無音でspeechFinal発火
  utterance_end_ms: 2000,   // フォールバック(speechFinal未発火時)
  vad_events: true,
  encoding: "linear16",
  sample_rate: 16000,
  channels: 1,
});

endpointing は当初2500msでしたが、応答までの体感が遅かったので1500msに短縮しました。utterance_end_msendpointing より長くしないと二重送信になるので2000msにしています。

Deepgram のイベントで大事なのは isFinalspeechFinal の区別です。isFinal は「このチャンクのテキストが確定した」で、1回の発話中に何度も来ます。speechFinal は「発話が終わった」で、最後に1回だけ来ます。

この speechFinal をトリガーにして Live API に turnComplete: true を送り、AIの応答を開始させています。さらに isFinal の時点でテキストを turnComplete: false で先行送信しておくと、Gemini が事前にコンテキストを処理し始めるので、speechFinal 後の応答が体感で速くなります。(多分)


Live APIの設定とVAD無効化

Live API のトークンは authTokens.create() で発行します。これもサーバー側でやります。

liveConnectConstraints: {
  model: "gemini-2.5-flash-native-audio-preview-12-2025",
  config: {
    sessionResumption: {},
    temperature: 0.7,
    responseModalities: [Modality.AUDIO],
    inputAudioTranscription: {},
    outputAudioTranscription: {},
    realtimeInputConfig: {
      // Live APIに音声を送らないのでVADは機能しない
      // disabled: true だとセッション維持に問題が出たので、
      // 60秒という長いsilenceDurationMsで実質無効化
      automaticActivityDetection: {
        silenceDurationMs: 60000,
      },
    },
    speechConfig: {
      languageCode: "ja-JP",
      voiceConfig: {
        prebuiltVoiceConfig: { voiceName },
      },
    },
  },
},

音声を Live API に送らない以上、内蔵 VAD は動きようがないんですが、試したときにdisabled: true にするとセッション自体が不安定になったので(?)、60秒を設定しています。多分GAの時のは直ってるじゃないでしょうか。

voiceConfigspeechConfigEphemeral TokenliveConnectConstraints に含めるのが確実です。クライアント側の live.connect() で設定しても反映されないケースがありました。

トークンは uses: n で発行(初回接続 + 再接続n回)。sessionResumption を有効にしているので、一時的な切断後も会話の文脈が保てます。


エコーキャンセルと会話の制御

AIとの対話は音声をマイクが拾って Deepgram が誤認識し、turnComplete が飛んでAIが途中で切れたります。 getUserMedia のエコーキャンセルは Web Audio API 経由の出力には効かないことがあるらしい(?)です。

github.com

Chromium

なので、出力音声のRMSを監視して AI が話している間はDeepgramの結果を捨てる方式にしました。

const checkAISpeaking = useCallback(() => {
  const analyser = getOutputAnalyserRef.current?.();
  if (!analyser) return false;
  let data = outputRmsDataRef.current;
  if (!data || data.length !== analyser.fftSize) {
    data = new Float32Array(new ArrayBuffer(analyser.fftSize * 4));
    outputRmsDataRef.current = data;
  }
  analyser.getFloatTimeDomainData(data);
  return calculateRms(data) >= 0.01; // OUTPUT_SPEAKING_THRESHOLD_RMS
}, []);

出力の AnalyserNode から波形を取って RMS を計算し、閾値以上なら「AIが話している」と判定します。

音声の入出力パイプラインとしては、入力側は AudioWorklet で Float32 → Int16 (PCM) に変換して Deepgram に送信、出力側は Live API から来る Base64 エンコードの PCM を Int16 → Float32 に変換して Web Audio API でスケジューリング再生しています。nextStartTime を追跡してチャンク間のギャップを防ぐことで、途切れのないようにしています。


Deepgram を追加している分 Gemini 単体構成よりコストは増えますが、日本語の認識精度とターン制御の自由度が増えたなーという感じです。