audio_stream_end=TrueはGemini Live APIのターンを終わらせない
Vertex上のgemini-live-2.5-flash-native-audioに対し、1本の5秒クリップで測定しました。音声のみでは0バイト、音声+audio_stream_end=Trueでも0バイト、音声+1.5 sの末尾無音では229,994バイトが返ります。サーバーがターンを終えるのは自身のVADが無音を聞いたときなので、アップリンクはポーズを実サンプルとして運ばなければならず、チャンクの欠落として運んではいけません。Google自身のドキュメントはこのフィールドについて2通りの異なることを述べており、Vertexのドキュメントには一切の記載がありません。
Gemini Live APIの上で音声エージェントを作っていて、接続はでき、音声はストリームされ、セッションは健全に見えるのに、一度も答えが返ってこない——そんな状況なら、この記事は、あなたがこれから費やすはずの2時間そのものです。
まず測定から。1本の5秒のLibriSpeechクリップを、同じVertexエンドポイント、同じモデル、同じセッション設定に対して3通りの方法で送ります。変えるのは、通話者が話し終えたことをクライアントがどう合図するかだけです。
| クライアントが送ったもの | 返ってきたバイト数 |
|---|---|
| 音声のみ、終了合図なし | 0 |
音声の後にsend_realtime_input(audio_stream_end=True) | 0 |
| 音声の後に1.5 sの末尾無音を実サンプルとして送信 | 229,994 |
24 kHz 16ビットモノラルの229,994バイトは、およそ4.8秒分の応答です。凝視する価値があるのは真ん中の行です。フィールドの名前はaudio_stream_end、あなたはそれをTrueに設定し、ソケットは開いたまま、エラーも返ってきません——それでもモデルは応答しないのです。永遠に。私たちは待ちました。
サーバーがターンを終えるのは、サーバー自身の音声区間検出(VAD)が無音を聞いたときです。 それが唯一のメカニズムです。したがって無音は、サンプルとして、実時間のレートでサーバーに届かなければなりません。単に送信をやめたクライアントは、話し終えたクライアントではありません。死んだクライアントと区別がつかないクライアントです。
ドキュメントが言っていること——それは2通りある
私たちは測定した後でこれをドキュメントに探しに行きましたが、ドキュメントは文字どおり自分自身と割れています。以下の3つの引用はすべて、Googleが公開しているリファレンスとガイドからのものです。
規範となるフィールドリファレンス、BidiGenerateContentRealtimeInputが記述しているのは、ターンではなくストリームの状態です。
「音声ストリームが終了したことを示します。たとえばマイクがオフにされた場合などです。これは自動アクティビティ検出が有効な場合(これがデフォルトです)にのみ送信すべきです。クライアントは音声メッセージを送ることでストリームを再開できます。」
Live APIガイドは、自動VADのセクションで、同じことを運用の言葉で述べています——フラッシュであって、完了ではありません。
「音声ストリームが1秒以上一時停止した場合(たとえばユーザーがマイクをオフにしたため)、キャッシュされた音声をフラッシュするために
audioStreamEndイベントを送信すべきです。クライアントはいつでも音声データの送信を再開できます。」
そして同じガイドのハイブリッドVADのセクションは、かなり違うことを言っています。
「サーバーは
audio_stream_endシグナルを、デフォルトのサーバー側無音検出遅延をバイパスする即時の確定プロンプトとして扱い、トランスクリプトとモデル応答を最小のレイテンシで返します。」
この両方がひとつのフィールドの一般的な挙動であることはあり得ず、3つ目は、これが応答を生むと述べている唯一の文です。一方で、これを含むメッセージに対するSDK自身の型は、モデルの実際の契約を述べています。
「ターンの終了は明示的に指定されるのではなく、ユーザーのアクティビティ(たとえば発話の終了)から導出されます。」
もうひとつ、これがこれほど間違えやすいことの本当の説明だと私たちが考えているものがあります。VertexのドキュメントはaudioStreamEndに一切言及していません。 VertexのLive APIのどのページにも、このフィールドを見つけることはできませんでした——そしてVertexこそ、gemini-live-2.5-flash-native-audioが住んでいる場所です。一般提供中で、低レイテンシの音声エージェント向けの推奨モデルとして、です。つまり私たちが測定したプラットフォーム上では、このフィールドは何かをするとは文書化されておらず、ターンを確定させると述べる段落はもう一方の製品のガイドにあるのです。
私たちはGoogleのドキュメントが間違っていると主張しているのではありません。主張しているのは、Vertex上で、自動VADがデフォルトのまま、このモデルで、この日付において、このシグナルは何も生まず、1.5秒の無音は完全な応答を生んだということ、そして即時確定についての文だけを読めば、ハングするものを作ることになる、ということです。
これに当たったのは私たちが最初ではありません。python-genai issue #1328は、手順まで含めてまさにこれを報告しています。マイクがオフになったときにaudioStreamEndを送り、そして「モデルがユーザーターンを終了も反応もしないことを、1分以上待った後でも観察する」。このissueは1月にstaleボットによって、修正も確認もないまま閉じられました。したがってこれは公式声明ではなく、別の開発者からの傍証として扱ってください——リンクではなく測定を公開しているのは、まさにそれが理由です。
これを直す不変条件
私たちはこれを一文の契約としてサービスに書き込みました。ファイル内の他のすべてはこの下流にあるからです。
通話がライブである間、連続した16 kHz PCM16ストリームが実時間レートでAPIに届き、ポーズは実際の無音サンプルとして運ばれる——欠落したチャンクとしては決して運ばれない。
これはスタイルの好みではありません。テスト可能な形に言い直したプロトコルです。チャンクの転送を見送り得るコードのあらゆる場所が、ソケットを開いたまま通話を永遠にハングさせ得る場所であり、私たちが抱えていた2か所はどちらもバグでした。
2つ目の帰結はブラウザクライアントを捕まえるもので、自明ではありません。あなたのクライアントは、望むと望まざるとにかかわらず、ポーズの間は音声の送信を止めます。
- Chromeは、上流のバスに無音のフラグが立つと、
AudioWorkletProcessorに空の入力配列を渡します。ゼロで埋まったバッファではなく——空の配列です。素朴なコードは何も転送しません。 - ミュートされたトラックやデバイスの切り替えは、コールバックそのものを止めます。
つまり「ユーザーが静かになった」と「マイクがコールバックを生成しなくなった」は、あなたのサーバーには同じイベントとして届きます。そしてサーバー側のVADが答えるために必要なただひとつのものこそ、ブラウザが渡さないと決めたまさにそれなのです。
私たちの修正はキープアライブです。クライアントが送るべきだった無音を合成し、同一の経路——同じリサンプラー、同じモデル——を通して押し込みます。これでパイプラインは一貫性を保ち、ストリームは穴ではなく真の無音サンプルを運びます。アイデアそのものより重要なディテールが2つあります。
- クライアントの到着にキーイングし、自分自身のアップリンクには決してキーイングしない。 私たちのアップリンクは正当にバースト的です。ストリームリサンプラーはおよそ95 msの束で出力するからです。自身の出力を見張るキープアライブは、バースト間の隙間をすべてクライアントの停止と読み、ライブの発話の真ん中に無音を注入してしまいます。私たちのものはブラウザから150 ms何も来なかった後に発火します。これは128サンプルの量子をおよそ19回取りこぼした長さであり、ジッタではなく停止です。
- ティックあたりの充填量に上限を設ける。 長い停止は、会話と釣り合わない巨大で遅れた塊として一度に届くのではなく、着実に修復されます。
そして、報告すること。通話ごとに合成された無音の量は、隠れた修復ではなく、私たちのヘルスペイロードの中のひとつの数字です。セッションの40%が合成無音なら、それはあなたが知りたいはずの、クライアントについての何かを語っているからです。
誰も予期しないインターロック
次に来たのはコアレッシング(結合)で、理由は別でした。A/Bの2つのアームが異なるパケット化をされていたのです——毎秒10.6フレーム対125.0フレーム——そして現在は両アームとも、送信前に固定100 msフレームへ組み立てます。Google自身のガイダンスは「レイテンシを最小化するため、小さなチャンク(20 msから40 msの間)を送信する」であり、毎秒125フレームは逆方向にその範囲を大きく外れています。
しかし、組み立て途中のフレームは、まだ送られていない音声です。つまりコアレッシングが安全なのは、キープアライブがあるからこそです。バッファの背後にプロデューサーがいなければ、通話者の最後の文の末尾は、「通話者が止まった」と検出するためにサーバーがまさにそれを必要とする瞬間に、半分埋まったフレームの中に座り込んでいます。キープアライブはバッファに常にプロデューサーがいることを保証するので、部分フレームは必ず、その後に続く無音によってフラッシュされます。
独立に見えて、分離できない2つの変更です。キープアライブなしでフレームのコアレッシングだけを取り入れれば、同じハングのより捉えにくい版を作ったことになります。
ターン検出の設定を明示的にする
最後に変えたのは、コードというより可視性です。このサービスは以前、すべてのVADパラメータを暗黙のままにしていました。だからこそ「エージェントが決して答えない」が、設定ではなく謎のように見えることができたのです。
参照用に、文書化されている面を示します。
| フィールド | ドキュメントの記述 |
|---|---|
automaticActivityDetection.disabled | デフォルトはfalse——サーバー側VADは有効 |
silenceDurationMs | 「サーバーの内部デフォルトはおよそ800msです」。ガイドは500–800 msを推奨 |
prefixPaddingMs | 私たちが見つけられた範囲では、デフォルトの記載なし |
startOfSpeechSensitivity / endOfSpeechSensitivity | SDKはプラットフォームごとに異なるデフォルトを記載——エンタープライズ側ブランチではLOW、Gemini APIブランチではHIGH |
activityStart / activityEnd | 「自動(すなわちサーバー側)アクティビティ検出が無効な場合にのみ送信できます」 |
この表には罠が2つあります。第一に、ガイド自身の設定例はprefix_padding_ms: 20とsilence_duration_ms: 100を使っています——説明用の値であり、100 msは、同じページが「低すぎる」と呼ぶ範囲、すなわち「自然なポーズでシステムが発話ターンを打ち切り、ひとつの発話を複数の小さな音声断片に分割してしまう」範囲の内側にあります。サンプルを本番にコピーしないでください。
第二に、手動のアクティビティ検出は補完ではなく、別世界です。disabled: trueではactivityStartとactivityEndを自分で送り、ガイドによれば「この構成ではaudioStreamEndは送信されません」。VADを自分で所有するなら、ターン境界も自分の所有物になり、無音の問題は消えます。あなたはただ、問題のより難しい半分——ノイズの多い回線でターンを検出すること——を買っただけです。
私たち自身の選択は、この5つすべてを環境変数と通話ごとのクエリパラメータとして公開し、デフォルトでは未設定のままにすることです。こうしてステータスエンドポイントは、サービスがでっち上げた数字ではなく「vertexのデフォルト」と報告します。動作することを私たちが測定済みの2つの構成はVertexのデフォルトで測定されたものであり、黙ってそれらを動かすことは、いまや証明済みの結果を変えてしまいます。
どうせ必要になる、音声コントラクトの残り
ドキュメントからの抜粋です。一度で正しく押さえる価値があります。
- 入力: 生のリトルエンディアン16ビットPCM。「入力音声はネイティブには16kHzですが、Live APIは必要に応じてリサンプルするため、任意のサンプルレートを送信できます。」 すべてのblobのMIMEタイプでレートを宣言してください。例:
audio/pcm;rate=16000。Vertexのトラブルシューティングページはチャネル数を付け加えています。単一のモノラルチャネルです。 - 出力: 生のリトルエンディアン16ビットPCM、常に24 kHz。非対称に注意してください——送るのは16 kHz、受け取るのは24 kHzです。下流でのミキシングや録音は、どちらかをリサンプルしなければなりません。
- チャンク化: 20から40 ms。送信前に1秒近くバッファしないでください。
- セッション長: コンテキストウィンドウ圧縮なしでは、音声のみのセッションは15分、音声+映像は2分と文書化されています。それとは別に、「WebSocket接続の制約により、接続の寿命はおよそ10分に制限されます」とあり、終了の60秒前に
goAway通知が送られます。これらは2つの異なる制限であり、短いほうは接続です。したがって再接続は長い通話の正常な一部であって、エラーパスではありません。 私たちはそのように扱っています。ギャップをまたぐマイクキューは有界で最古から落とす方式なので、再接続が、膨らんだバックログを再生して10秒前の質問に答えることで「回復」することはありません。
これらのドキュメントの所在についてひとつ注意を。VertexのLive APIページはリブランドされて移動しており、旧来のcloud.google.com/vertex-ai/generative-ai/docs/live-apiのパスは現在リダイレクトされます。あなたのメモの中のリンクが死んでいるなら、それが理由です。
ただし書き
- これは刺激に関してn = 1です。 1本の5秒クリップ、3条件、1モデル(
gemini-live-2.5-flash-native-audio)、1リージョン、1日付。ある挙動のクリーンで再現可能なデモンストレーションであって、サーベイではありません。私たちはこのバイト数をベンチマークであるかのように報告するつもりはありません。 - 0 / 0 / 229,994という結果は、その構成において無音が機能し、フラグが機能しなかったことのデモンストレーションです。
audio_stream_endがどのモデルでも、どの設定でも、決して何もしないことを立証するものではありません。Vertex上でこれがターンを確定させるところを示せるなら、その構成をぜひ見せてください。 - この挙動はVertex上で測定されたものです。 Gemini APIブランチは異なる感度デフォルトを文書化しており、確定を記述するハイブリッドVADの段落はそちらのブランチのガイドにあります。
- コミュニティのissueは傍証であって、確認ではありません。 解決のないまま自動化によって閉じられました。
- 私たちはここで品質を測定していません。 この記事のどこも、モデルが通話者をどれだけよく聞き取ったかについては何も述べていません。それは別の実験であり、その前段にある音声経路こそ、私たちが実際に取り組んでいるものです。
短い版
- ターンを終わらせるのはサーバー自身のVADだけです。 それだけを前提に設計してください。
- ポーズはサンプルとして運ぶこと。 クライアントが静かになったら、無音を合成し、実音声と同じ経路で送ってください。
- キープアライブを自分自身のアップリンクに決してキーイングしないこと。 バースト的な送信経路は停止したクライアントに見え、ライブの発話をずたずたにします。
- 固定フレームへのコアレッシングは、バッファに常にプロデューサーがいることを何かが保証して初めて行うこと。 この2つの変更はひとつの変更です。
- セッションごとに配送音声比を公開すること。 アップリンク秒数を実時間秒数で割った値には1.0近傍という既知の正解があり、それによって誤った値が読めるようになります。私たちのものは、UIが明るくライブセッションを報告し続ける中、何日も0.08を示していました。それはそれ自体でひとつの記事です。
- VADの例示値をコピーしないこと。 未設定のままにし、何が有効なのかを報告し、変えるときは意図的に変えてください。