TT Lab
博客

한국어 English 日本語 中文

开始
博客

博客

服务器听不见的麦克风:将 Gemini Live API WebSocket 示例更新为当前格式的历程

结果:已合并 (2026-10-27 18:04, google-gemini/cookbook#1433). 实际上,这些内容取自 GitHub 的 PR、审阅、行评论、评论、时间线、检查记录,以及 Cookbook 源码(PR 的基础提交 d9df60de)、Live API 参考文档、贡献账本的初稿和回复初稿,以及 TT Lab 仓库的语音中继代码。时间均为韩国时间。

目录

一句话总结

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 一个文件。输入字段、声音 MIME 格式、接收端循环、解码。两个提交,增加 29 行,删除 34 行
反应 审阅机器人的一个建议,审阅者的“变更请求”(附带亲自复现和额外的一行),另一位审阅者的离线检查,最后在实际语音对话测试后批准
结果 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 连续发送了四个问题,分别用四种语言正确回答,并且说话结束后第一段声音 为 0.88–1.48 秒。同时附带说明,由于是单次测量,速度比较尚未实际测量的提示。

区分我们测量过的内容和未测量过的内容。根据上述记录,我们的中继能够使用当前字段正常运行。然而,如果按原样运行此示例, media_chunks 是否真的被忽略了并没有进行测量(未实际测量)。因为需要麦克风、摄像头和 API 密钥,所以在 PR 正文中写道:“仅用 Python compile() 确认了语法,没能运行到最后。”因此,最初的 PR 的分量大约是“整理废弃字段,替换为当前字段”的程度。这个问题有多严重,审查者在第 4 节中告诉了我们。哪里出现了偏差,在第 2 节中逐行记录。

2. 原因:示例中的哪一行

Gemini Cookbook 中有两套做同样事情的示例。使用 SDK 的 quickstarts/Get_started_LiveAPI.py 和直接使用 WebSocket 的 quickstarts/websockets/Get_started_LiveAPI.py 。在阅读与我们的中继方式相同的第二个示例时,我发现有四个地方与参考文档不同。行号基于 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 中清空)。两套示例做同一事情时只修改其中一侧的情况似乎很常见。贡献日志的草稿备忘录中写道,WebSocket版本的最后一次实际变更是 2025-11,此后仅修改了模型名称。

3. 修改了什么:两个提交

第一个提交(10-17)修复了第2节中的四项内容(+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。其中有一个没有故意加入。这个示例将 API 密钥作为 WebSocket 地址的 ?key= 发送,而 SDK 在同一个 WebSocket 中将密钥作为 x-goog-api-key 头发送。地址很容易留在日志和异常消息中。不过,由于我们没有重新验证直接使用 WebSocket 的一端是否支持头部认证,且参考文档中也没有记录,因此我们没有将其放入 PR 中,仅在正文末尾作为“另行讨论的话题”记录。日志中没有以此建议发起过 Issue 的记录。

第二个提交(10-19)反映了评审意见(第4节)。将两处使用 raw_response.decode("ascii") 解析 WebSocket 帧的地方(配置响应和接收循环)改为了 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) 的帮助下准备,并经我审查和确认。”贡献日志的草稿备忘录中写道,此仓库的贡献指南中 没有 AI 政策措辞。第一个提交中没有 AI 脚注,只有 DCO 签名和 SSH 签名,第二个提交中带有 Assisted-by: Claude 和 DCO 签名,但没有 SSH 签名。该仓库要求谷歌 CLA,且通过了相关检查。

4. 反应:“比看上去更大的修改”

时间 日
10-17 17:22 公开 PR。标签 status:awaiting review·component:quickstarts
10-17 17:22–17:24 审查机器人 (Gemini Code Assist) 给出了总结和一个行建议: modelTurn·inlineData 为显式 null 时, .get(…, {}) 将返回 None ,因此 (… or {}) 来使用
10-19 19:57 审阅者“更改请求”。要求附带直接复现结果再修改一行
10-19 22:03 第二个提交,22:13 回复
10-20 01:01 补充了使用假 WebSocket 运行结果的回复
10-20 06:01 另一位评审员给出了“无阻碍问题”的评审,进行了八项离线检查
10-22 16:51 第一位评审员通过实际语音对话测试到最后,并给出了“LGTM”
10-22 16:57 请求将“变更请求”状态更改
10-22 18:29, 19:19 评审员向维护者请求评审,并将其更改为批准
10-27 18:04 该维护者进行了 squash 合并

这个变更请求是一个令人感激的变更请求。评审员表示这是一个“比看上去更大的修改”,并 gemini-3.8-live 表示曾通过 WebSocket 直接向模型发送了相同的语音片段。 media_chunks 使用 (rate= 无论有无) 都没有任何反应,而使用 realtime_input.audio 则得到了正确的回答。也就是说,在 main 的示例中,麦克风实际上已经 失效了。他还表示确认了 realtime_input.video 和 interrupted 的处理。并且他还请求了另一行代码。服务器默认会发送回答的字符(outputTranscription), 如果将帧解包为 ASCII,在非英文的回答中就会崩溃。他提到在 main 中输入 “Say hello in Japanese.” 即可重现。

他在同一天的第二个提交中进行了修复,并回复称配置响应中也有相同的 decode("ascii") ,因此一起进行了修复。此时他写道,仅在本地确认了包含日语字符的一个帧现在能够正常解包,并未在实际服务器上重新运行。几小时后,他又补充了将接收循环本身连接到虚拟 WebSocket(无网络、无音频,Python 3.12)运行的结果。当放入日语字符帧、 modelTurn: null 帧、 inlineData: null 片段、 turnComplete 这四个内容时,修复前会在第一个帧处 被 UnicodeDecodeError: 'ascii' codec can't decode byte 0xe3 阻断,修复后这四个都能正常处理,音频片段到达了播放队列。这里他再次写道“未在实际服务器上运行”。这两条回复作为草稿留在了原始记录中。

另一位评审员执行了八项无需实际设备即可进行的检查,称“没有阻碍性问题”。他将示例的类 ast 提取出来,连接了虚拟相机、屏幕和麦克风, 并确认了多个片段、二进制与文本帧、 turnComplete 之后的剩余播放、 interrupted 清空的播放以及 Unicode。他还表示如果在 main 上运行相同的检查, 旧问题会再次出现。并且附带了说明,表示没有使用实际的麦克风、扬声器和服务器进行运行。

最后的确认由第一位评审员完成。他用第二个提交测试到了最后,确认可以通过语音进行长达 5 分钟的对话(在 main 中模型完全听不到麦克风),发送屏幕画面能够被识别,即使以第一条消息让其用日语打招呼,也能完成播放和“End of turn”的输出。他还指出了一同找到的配置响应解码。由于该评审员的评审状态仍为“变更请求”从而导致合并被阻止,因此在致谢的同时请求更改了一次状态,2小时22分钟后状态变更为批准。合并在五天后由维护者完成。合并提交 db6fb41f 是将两个提交合并的 squash, 作者是我们的账号。

5. 心得体会:开源贡献要领

Titi 的一言。 对讲机使用手册上写着“请用旧按钮说话”。但那个按钮现在已经没有连接到任何东西了。此外,每当对方说完话,它就会删掉还没听过的话。我递过去一张说明换成新按钮的字条后,老师亲自拿着对讲机试了一下,说:“这比想象的要严重啊,用旧按钮根本听不见!”而且他还发现用日语回答会让对讲机卡住。连那个也修复后,我们聊了 5 分钟,他还给了我一个“赞”。

链接

所有资料均于 2026-10-29 查阅。

登录后即可点赞

评论

还没有评论。

登录后即可发表评论