結果:マージ(2026-10-27 18:04、 google-gemini/cookbook#1433). 実際には、GitHubのPR・レビュー・行コメント・コメント・タイムライン・チェック履歴、Cookbookのソース(PRのベースコミット
d9df60de)、Live APIリファレンス、コントリビューション原簿の下書きと返信の下書き、TT Labリポジトリの音声中継コードから取得しました。時刻は韓国時間です。
目次
- 1行要約
- 1. 何が問題だったのか:サンプルとリファレンスが乖離していた点
- 2. 原因:サンプルのどの行か
- 3. 何を変更したのか:2つのコミット
- 4. 反応:「思ったより大きな修正」
- 5. 学んだこと:オープンソースへのコントリビューションのコツ
- リンク
1行要約
| PR | google-gemini/cookbook#1433 websockets Live API starter: use realtime_input.audio/video and flush playback only on interrupted |
| 変更点 | quickstarts/websockets/Get_started_LiveAPI.py 1ファイル。入力フィールド、音声MIME形式、受信ループ、デコード。2つのコミット、29行追加、34行削除 |
| 反応 | レビューボットからの1つの提案、レビュアーによる「変更リクエスト」(実際の再現とともにもう1行の修正)、別のレビュアーによるオフラインチェック、最後に実際の音声会話テストを経て承認 |
| 結果 | PR公開 10-17 17:22 → 変更リクエスト 10-19 19:57 → 反映 22:03 → 承認 10-22 19:19 → マージ 10-27 18:04、約10日41分 |
1. 何が問題だったのか:サンプルとリファレンスが乖離していた点
TT Labの音声会話機能は、サーバーがブラウザとGemini Live APIの間をWebSocketで中継する仕組みです(TT Labリポジトリ backend/app/lang_call.py)。SDKを使わずにWebSocketプロトコルを直接扱い、マイクの音声は realtimeInput.audio に audio/pcm;rate=16000 として送信し、サーバーが serverContent.interrupted を送信するとブラウザに溜まった再生をクリアし、 turnComplete ではクリアしません。そのコードのコメントには、2026-10-09に質問を4つずつ送信して4つの言語で正しく回答され、発話終了後の最初の音声が0.88~1.48秒だったという記録があります。1回ずつ測定した値であるため、速度の比較は未計測であるという注記も一緒に記載されています。
測定したことと測定していないものを切り分けます。私たちの中継が現行のフィールドでうまく動作することは、上記の記録通りに測定しました。しかし、このサンプルをそのまま動かして media_chunks が本当に無視されるのかは測定していませんでした(未計測)。マイク・カメラ・APIキーが必要だったため、PRの本文には「Python compile() で文法のみを確認し、最後まで実行することはできなかった」と書きました。そのため、最初のPRは「非推奨フィールドを現行フィールドに整理する」程度の重みでした。この問題がどれほど大きいかは、第4節でレビュアーが教えてくれました。何が食い違っていたのかは、第2節に行単位で記載します。
2. 原因:サンプルのどの行か
Gemini Cookbookには同じ事を行うサンプルが2つあります。SDKを使用する quickstarts/Get_started_LiveAPI.py と、WebSocketを直接使用する
quickstarts/websockets/Get_started_LiveAPI.py です。私たちの中継と同じ方式である2つ目を読んでいると、4箇所がリファレンスと異なっていることがわかりました。行番号はPRのベースコミット
d9df60de を基準としています。
| 行 | サンプルが行っていたこと | 基準 |
|---|---|---|
| 151 | カメラ画面を {"media_chunks": [frame]} に |
Live APIリファレンスは mediaChunks を非推奨としてマークし、 audio·video·text のいずれかを使用するよう求めている |
| 181 | 画面共有を {"media_chunks": frame} に(リストではなく単一のオブジェクト) |
上の151行目とも異なる |
| 204–210 | マイク音声を media_chunks に、MIMEは "audio/pcm" |
他のLiveサンプルは audio/pcm;rate=16000 |
| 214–242 | parts[0] のみを再生し、 turnComplete ごとに再生キューをクリアする |
割り込みシグナルは interrupted。SDK版サンプルはすでに「interrupted の場合のみクリアし、すべてのチャンクを再生」 |
受信側ループのコメントには、「モデルの発話を遮るとターンの終わり(end of turn)が送信されるため、割り込みができるようにキューをクリアする必要がある」と書かれていました。しかし、ターンの終わりは遮らなくてもすべての返答ごとに届きます。返答が終わるたびに、まだ再生されていない音声が破棄されてしまうことになります。SDK版サンプルの
187–199行目
はすでに interrupted に修正されていました。
まとめると、WebSocket版サンプルはプロトコルが変更された際に同時に修正されず、同リポジトリのSDK版サンプルのみが修正されていました。リファレンスが mediaChunks を非推奨として示しているにもかかわらず、WebSocket版はそのフィールドで送信しており、割り込み処理もSDK版が変更された後の古い方式(turnComplete でクリア)のまま残っていました。2つのサンプルが同じことを行う場合、片方だけが修正されることはよくあるようです。コントリビューション原簿の下書きメモには、WebSocket版の実質的な最後の変更は2025-11であり、その後はモデル名のみが変更されたと記載されています。
3. 何を変更したのか:2つのコミット
最初のコミット(10-17)では、第2節の4つの点を修正しました(+24/−30)。
- msg = {"realtime_input": {"media_chunks": [frame]}}
+ msg = {"realtime_input": {"video": frame}}
...
- "media_chunks": [ { "data": ..., "mime_type": "audio/pcm" } ]
+ "audio": {
+ "data": base64.b64encode(data).decode(),
+ "mime_type": f"audio/pcm;rate={SEND_SAMPLE_RATE}",
+ }
...
+ # If you interrupt the model, the server sets `interrupted`.
+ if server_content.get("interrupted"):
+ while not self.audio_in_queue.empty():
+ self.audio_in_queue.get_nowait()
+
+ # A single message can carry several parts, so play all of them.
+ for part in server_content.get("modelTurn", {}).get("parts", []):
+ if b64data := part.get("inlineData", {}).get("data"):
+ self.audio_in_queue.put_nowait(base64.b64decode(b64data))
+
+ if server_content.get("turnComplete"):
+ print("\nEnd of turn")
ファイルがCRLFの改行文字を使用していたため、パッチもCRLFを維持しました。1つは意図的に入れませんでした。このサンプルはAPIキーをWebSocketのアドレスの ?key= として送信しますが、SDKは同じWebSocketでキーを x-goog-api-key ヘッダーとして送信します。アドレスはログや例外メッセージに残りがちです。ただし、WebSocketを直接使用する側でヘッダー認証が機能するかどうかを私たちは検証しておらず、リファレンスにも記載されていないため、PRには含めず本文の最後に「別途議論すべき点」としてのみ記載しました。この提案によってIssueを開いた記録は原簿にありません。
2つ目のコミット(10-19)ではレビューを反映しました(第4節)。WebSocketフレームを raw_response.decode("ascii") でデコードしていた2箇所(設定の応答と受信ループ)を json.loads(raw_response) に変更しました。 json.loads はバイトを受け取ってUTF-8でデコードします。ボットが提案した通り .get("modelTurn", {}) を (server_content.get("modelTurn") or {}) に変更し、値が明示的な null である場合も空の辞書として扱うようにしました(inlineData も同様)。マイクストリームが開いていないときに close() でクラッシュしないよう if self.audio_stream: も追加しました。最終的なdiffは29行追加、34行削除です。
AIの使用については、PR本文の最後にある「AIアシスタント(Claude)の助けを借りて準備し、自分がレビュー・確認した」という1文で明らかにしました。コントリビューション原簿の下書きメモには、このリポジトリのコントリビューションガイドラインにAIポリシーの文言がないと記載されています。最初のコミットにはAIのフッターはなくDCO署名とSSH署名のみがあり、2つ目のコミットには Assisted-by: Claude とDCO署名はあるがSSH署名はありません。このリポジトリはGoogle CLAを要求しており、そのチェックは通過しました。
4. 反応:「思ったより大きな修正」
| 時刻 | 日 |
|---|---|
| 10-17 17:22 | PR公開。ラベル status:awaiting review·component:quickstarts |
| 10-17 17:22–17:24 | レビューボット(Gemini Code Assist)による要約と1つの行提案: modelTurn·inlineData が明示的な null である場合、 .get(…, {}) は None を返すため、 (… or {}) を使用するよう指摘 |
| 10-19 19:57 | レビュアーによる「変更リクエスト」。実際の再現結果と共にもう1行の修正を依頼 |
| 10-19 22:03 | 2つ目のコミット、22:13 返信 |
| 10-20 01:01 | ダミーのWebSocketで実行した結果を追加した返信 |
| 10-20 06:01 | 別のレビュアーによる「ブロックする問題なし」のレビュー。8つのオフラインチェック |
| 10-22 16:51 | 最初のレビュアーによる実際の音声会話を通した最後のテストと「LGTM」 |
| 10-22 16:57 | 「変更リクエスト」の状態を変更するよう依頼 |
| 10-22 18:29, 19:19 | レビュアーがメンテナーにレビューを依頼し、承認に変更 |
| 10-27 18:04 | そのメンテナーによるスクワッシュマージ |
変更リクエストはありがたい変更リクエストでした。レビュアーは「思ったより大きな修正」であり、 gemini-3.8-live モデルにWebSocketで直接同じ音声クリップを送信してみたと述べました。
media_chunks では(rate= があってもなくても)何の反応もなく、 realtime_input.audio では正しい返答が返ってきたとのことです。つまり、mainのサンプルではマイクが事実上死んでいました。 realtime_input.video と interrupted の処理も確認したとのことです。そして、もう1行の修正を求めました。サーバーはデフォルトで返答のテキスト(outputTranscription)を一緒に送信しますが、フレームをASCIIでデコードすると英語以外の返答でクラッシュするというものです。mainで「Say hello in Japanese.」と入力すると再現すると述べました。
同日に2つ目のコミットで修正し、設定の応答にも同様の decode("ascii") があったため一緒に修正したと返信しました。この時点では、日本語の文字を含む1つのフレームがデコードできるようになったことだけをローカルで確認し、実際のサーバーには再度実行していないと記載しました。数時間後には、受信ループ自体をダミーのWebSocket(ネットワーク・音声なし、Python 3.12)に接続して実行した結果を追加しました。日本語の文字フレーム、 modelTurn: null フレーム、 inlineData: null チャンク、 turnComplete の4つを入力した際、修正前は最初のフレームで
UnicodeDecodeError: 'ascii' codec can't decode byte 0xe3 で停止し、修正後は4つすべてを処理して音声チャンクが再生キューに到達しました。ここでも「実際のサーバーには実行していない」と改めて記載しました。これら2つの返信は原簿の下書きとして残っています。
別のレビュアーは、実機なしで行える8つのチェックを実行し、「ブロックする問題はない」と述べました。サンプルのクラスを ast に取り出してダミーのカメラ・画面・マイクを接続し、複数のチャンク、バイナリ・テキストフレーム、 turnComplete の後も残る再生、 interrupted でクリアされる再生、ユニコードを確認したとのことです。同じチェックをmainに実行すると古い問題が再現されるとも述べました。実際のマイク・スピーカー・サーバーでは実行していないという注記も添えられていました。
最後の確認は最初のレビュアーが行ってくれました。2つ目のコミットで最後までテストし、5分間音声で会話ができ(mainではモデルがマイクの声を全く聞き取れない)、画面を送信すると認識し、最初のメッセージとして日本語の挨拶をさせても再生と「End of turn」の出力までできたとのことです。設定応答のデコードを一緒に見つけたことも挙げてくれました。このレビュアーのレビュー状態がまだ「変更リクエスト」であったためマージがブロックされており、お礼の言葉とともに状態を変更してほしいとお願いしたところ、2時間22分後に承認に変更されました。マージは5日後にメンテナーが行いました。マージコミット db6fb41f は2つのコミットを合わせたスクワッシュであり、作者は私たちのアカウントです。
5. 学んだこと:オープンソースへのコントリビューションのコツ
- 「2つのサンプルは互いを基準にします。」 同リポジトリのSDK版サンプルがすでに正しい処理を行っていたため、「リファレンスに合わせよう」よりも「隣のサンプルと同じにしよう」の方が短い根拠となりました。
- 「測定できなかったものはできなかったと書けば、レビュアーが測定してくれます。」私たちはこのサンプルを最後まで実行できず、そのように書きました。レビュアーは自分で測定した上で、「mainではマイクが死んでいる」という私たちが主張していないさらに大きな事実を見つけてくれました。逆に「実行してみた」と誇張していれば、この会話は生まれなかったと考えられます。
- 「ボットの小さな提案はすぐに受け入れます。」
(… or {})は些細に見えますが、レビュアーも「任意だがボットの指摘も」として同じ点を挙げていました。正しい指摘は議論せずに取り入れる方が、レビューを短くすませられます。 - 「「変更要求」の状態は自然には解除されません。」レビューアーがコメントで LGTM をしてもレビュー状態がそのままだとマージがブロックされます。感謝の言葉とともに状態を変更してもらうよう丁寧にお願いすることが次のステップです。
- 「コミットごとに告知と署名を合わせて確認します。」1番目のコミットには AI のフッターが、2番目のコミットには SSH 署名が抜けていました。本文の告知があったため問題にはなりませんが、原則とは異なります。
TT の一言。 トランシーバーの使い方に関する本に「古いボタンで話してください」と書かれていました。そのボタンはもうどこにも繋がっていないというのにです。そのうえ、相手が話し終えるたびにまだ聞いていない言葉を消してしまいました。新しいボタンに変えたメモをお渡ししたところ、先生が自らトランシーバーを持って試され、「これは思っていたより大事だな、古いボタンでは全く聞こえない!」とおっしゃいました。そして、日本語で返答するとトランシーバーが停止するということも見つけてくださいました。そこまで直したところ、5分間雑談して「いいね」をくださいました。
リンク
すべての資料は 2026-10-29 に閲覧しました。
- PR: google-gemini/cookbook#1433 (マージコミット
db6fb41f) - 修正した例(ベースコミット):
quickstarts/websockets/Get_started_LiveAPI.py - SDK版の例(ベースコミット):
quickstarts/Get_started_LiveAPI.py187–199行 - Live API リファレンス: https://ai.google.dev/api/live#BidiGenerateContentRealtimeInput