兰 亭 墨 苑
期货 · 量化 · AI · 终身学习
首页
归档
编辑文章
标题 *
URL 别名 *
内容 *
(支持 Markdown 格式)
# 实时语音对话功能:实现回顾与踩坑记录 > 目标:让孩子在查完一个单词后,能就**这个单词**跟 AI 直接开口对话(练口语),而不是只读例句。 > 最终形态:词卡 **⚡** 进入全双工实时语音(Gemini Live,可随时打断);词卡 **🎙** 保留为零配置兜底;**不翻墙也能用**(经 Cloudflare Worker 中转)。 > 本文记录真实过程:包括我做错的判断、被推翻的结论、每个坑的定位方法与修法,以及可复现的操作手册。 --- ## 一、最终形态与完整链路 一次对话涉及四段链路,任何一段断掉,用户看到的现象都是同一句话——"连不上": ``` ① 采集 浏览器 getUserMedia → AudioContext(16kHz) → AudioWorklet → PCM16 ② 传输 WebSocket(16kHz PCM16 上行 / 24kHz PCM16 下行) ③ 中转(可选但关键)浏览器 → wss://<你的站点>/api/uapi/live → Cloudflare Worker → Google ④ 模型侧 setup → setupComplete → 服务端 VAD 判回合 → serverContent(音频 + 字幕) ``` 功能入口与降级关系: | 入口 | 实现 | 依赖 | 定位 | |---|---|---|---| | **⚡** | Gemini Live(WebSocket 全双工) | 走代理则无依赖;直连需 Google Key | 首选体验,可打断 | | **🎙** | 浏览器 Web Speech API + Worker TTS | 仅需 Chrome/Edge/Safari | 零配置兜底,Firefox 下可打字 | 这个"双入口"不是设计洁癖,而是被现实逼出来的:Firefox 没有 Web Speech;代理可能挂;额度可能耗尽;网络可能异常。任何一个发生,孩子都还有得用。事实证明这个决定是对的——**⚡ 上线过程中的每一个阶段,🎙 都处在可用状态**。 --- ## 二、前置盘点:决定了方案只能怎么选 动手之前先盘现有资产,避免重复造轮子。这一步花的时间最少,价值却最高。 **已有的** - Worker 端自然音色 TTS(`/api/uapi/tts`,Edge TTS 走 WebSocket,输出 24kHz MP3,带 Google TTS 兜底与边缘缓存)。也就是说,"AI 说话"这半已经很强,不需要再做。 - 自建 AI 网关:OpenAI 兼容的 `/v1/chat/completions`,密钥在服务端,浏览器只拿到一个可用的 baseUrl。 - 纯静态托管(Cloudflare Pages)+ 一个 Cloudflare Worker,**没有常驻服务器**。这个约束贯穿所有设计。 - 例句语料、词卡、AI 释义缓存(`Storage.cacheGet('meanings', word)`)——对话要用的上下文素材现成。 **缺的** - 语音识别(STT):全仓搜不到任何 `SpeechRecognition`。 - WebSocket 中转:Worker 只会**出站** WS(给 Edge TTS 用),不会**接**浏览器的 WS。 **于是三条路** | 方案 | 延迟 | 新增成本 | 结论 | |---|---|---|---| | A 浏览器原生识别 + 现有 TTS | 1–3 秒/轮 | 零 | 先做,作为兜底 | | B 云端识别(Whisper 等经网关) | 1–3 秒/轮 | 需改网关、按分钟计费 | 识别率不够时再上 | | C 真·实时(Gemini Live) | 亚秒级、可打断 | 需 WS 中转 | 用户选定为首选 | **最终决策:C 不做成"替换 A",而是"A 兜底 + C 首选"。** 这个决定在后面的排障里反复体现价值:我可以在不确定的情况下先把 A 发上线,再慢慢打磨 C。 另外有一个容易被忽略的盘点结论:**这个功能不需要新的语料管线**。语音对话是"运行时"能力,和"构建期"的语料分片完全解耦——所以它没有背上"改一次要重建 20–40 分钟"的包袱。这一点在选型时就应该确认,否则很容易把一个轻功能做重。 --- ## 三、协议攻关:不靠记忆,去拿权威 schema 这是整个功能里最值得记录的一段方法。 **坑 1:环境把"查资料"这条路堵了。** 沙箱里 `web_fetch` 对任何域名都返回 `URL hostname resolves to a non-public IP address`,联网搜索 MCP 也连续失败。我一开始想凭记忆写 Live API 的协议——这是很危险的:模型名、字段名、音频格式、消息信封,任何一处记错都会表现为"连不上",而且报错信息往往极其含糊,甚至**根本没有报错**(服务端直接静默忽略)。 **解法:换通道。** 我发现 `bash` 里有正常网络(`curl` 能通),于是直接用 curl 抓官方文档: ```bash curl -sL https://ai.google.dev/api/live # 协议参考(消息信封、字段表) curl -sL https://ai.google.dev/gemini-api/docs/live # 指南(模型清单) curl -sL 'https://generativelanguage.googleapis.com/$discovery/rest?version=v1beta' # discovery,权威 schema ``` 拿到的**确定事实**(后续全部被实测证实): - **端点**:`wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContent` - **客户端消息**(每条只能含其中一个字段):`setup` / `clientContent` / `realtimeInput` / `toolResponse` - **服务端消息**:`setupComplete` / `serverContent` / `toolCall` / `toolCallCancellation` / `usageMetadata` - **`realtimeInput` 字段**:`mediaChunks`(已标 DEPRECATED)、`audio`、`video`、`text`、`activityStart`、`activityEnd`、`audioStreamEnd` - **`BidiGenerateContentSetup`**(discovery 文档核实):`model`、`generationConfig`、`systemInstruction`、`tools`、`realtimeInputConfig`、`inputAudioTranscription`、`outputAudioTranscription`、`sessionResumption`、`contextWindowCompression`、`historyConfig` - **`RealtimeInputConfig`**:`turnCoverage`、`automaticActivityDetection`、`activityHandling` - **打断语义**:`activityHandling` = `START_OF_ACTIVITY_INTERRUPTS`(默认,即"抢话即打断")/ `NO_INTERRUPTION`;而 `activityStart`/`activityEnd` **仅在关闭服务端 VAD 时才允许发送** - **VAD 参数**:`automaticActivityDetection.prefixPaddingMs` / `silenceDurationMs` - **该 key 可用的 Live 模型**(58 个可见模型里有 8 个支持 `bidiGenerateContent`):`gemini-3.8-live`、`gemini-3.8-live-extended-thinking`、`gemini-3.1-flash-live-preview`、`gemini-2.5-flash-native-audio-latest`(及 09/12 预览版)、`gemini-3.5-transcribe-live`、`gemini-3.5-live-translate-preview`、`gemini-robotics-er-2-streaming-preview` **方法论**:协议类集成,宁可用十分钟去拿权威文本,也不要凭记忆写一版、然后花两小时猜"为什么连不上"。这一步直接省掉了后面无数轮试错。 **一个刻意的保守决定**:`speechConfig`(音色配置)在文档里只看到字段名,没核到嵌套结构,所以**故意不发**,用默认音色——宁可少一个特性,也不引入一个可能让 `setup` 失败的未知结构。同理没有发 `contextWindowCompression`。**在"无法快速验证"的环境里,减少变量比增加特性重要。** 还有一个副产品:抓文档时顺手确认了 `models` 列表接口的返回结构,于是"这个 key 能不能用、能用哪些模型"变成了一条可以随时重跑的 curl 命令——而不是一个需要问人的问题。 --- ## 四、实现:音频管线 ### 上行(孩子说) ```js micCtx = new AudioContext({ sampleRate: 16000 }); // 直接要 16k,让浏览器做重采样 resampleRatio = micCtx.sampleRate / 16000; // 浏览器可能忽略请求(Safari),记下比值 await micCtx.audioWorklet.addModule(blobUrl); // worklet 用 Blob URL 内联,保持单文件产物 micNode.port.onmessage = (e) => pushPcm(new Int16Array(e.data)); ``` - Worklet 内把 Float32 转 PCM16,并用 `postMessage(buf.buffer, [buf.buffer])` 零拷贝传出。 - 前端按 **2048 采样(约 128ms)** 攒包再发,降低消息数量: `{ realtimeInput: { audio: { data: base64, mimeType: 'audio/pcm;rate=16000' } } }` - 若 `sampleRate !== 16000`,走线性插值重采样兜底。 - Worklet 必须连到 `destination` 才会被持续拉取,但串一个 `gain = 0` 的节点,避免把麦克风回放到耳机(否则会形成回声)。 **为什么用 AudioWorklet 而不是 ScriptProcessor**:后者已废弃,且在部分浏览器里会在主线程跑、导致界面卡顿。Worklet 跑在音频线程,是唯一正确的选择。 **为什么在 AudioContext 上直接要 16k**:这是最省事的采样率转换方式——交给浏览器的音频引擎做,比自己写重采样更稳。但要留一个兜底分支,因为 Safari 历史上会忽略这个请求。 ### 下行(AI 说) - 收到 `serverContent.modelTurn.parts[].inlineData.data`(base64 编码的 PCM16 24kHz)→ 解成 Float32 → 用 `AudioBufferSourceNode` 按 `playAt` 时间戳**顺序排队播放**,保证连续不断句。 - 维护一个"下一段该在什么时候播"的时间轴:若上一段已播完(`playAt < currentTime`),就留 20ms 余量从头开始;否则接着排。 ### 打断 - 服务端 VAD 检测到孩子抢话 → 下发 `serverContent.interrupted` → 客户端停掉已排队的音源、重置时间轴。 - **坑:不要在打断时 `close()` AudioContext。** 初版我这么写了,而浏览器对每个页面可创建的 AudioContext 数量有硬上限(约 6 个),连续打断几次之后就再也建不出来,表现为"突然没声音"。改成"只停 source、保留 context",会话结束才 close。 ### 字幕 - 打开 `inputAudioTranscription` / `outputAudioTranscription` 后,孩子的原话和 AI 的回话都会以文本增量下发,直接渲染成气泡。 - 这既是体验(孩子看得见自己说了什么),也是**排障利器**:能立刻区分"模型没听见"和"模型没回话"——这个区分在坑 2 里起了决定性作用。 --- ## 五、坑清单:现象 → 定位 → 根因 → 修法 ### 坑 1(最该记的):无头浏览器测不出 WebSocket —— 我因此得出了错误结论 - **现象**:用无头 Chrome 测浏览器侧 WS,12 秒内 `onopen`/`onerror`/`onclose` **一个都不触发**;而同一台机器上 Node 连同一个地址 2 秒就通。 - **我的错误结论**:据此判断"浏览器发 WS 会带 `Origin` 头,Google 拒绝浏览器直连",并且已经开始准备据此改架构(去做代理)。 - **推翻它的关键动作**:做**对照实验**——让同一个无头浏览器去连**公共 echo WebSocket**(Postman echo、Binance)。结果同样"毫无事件"。这说明问题在**测量工具**,不在被测对象。 - **根因**:`--virtual-time-budget` 会推进虚拟时间,但它**不等 WebSocket 握手**(而 `fetch` 会被等),于是断言脚本在握手完成前就"跑完"了,我读到的是一个空结果。 - **修法**:换成"页面用 `navigator.sendBeacon` 把结果 POST 回本机 + 真实 `sleep` 等待"的方式重测。结果:**浏览器 1.99 秒就连上了 Google Live**,我之前的结论完全作废。 - **教训**:**测试工具本身要先被验证。** 一个没有对照组的失败观测,不足以支撑架构决策。如果当时没做这个对照,我会去做一个根本不需要的代理(虽然最后因为"不翻墙"的需求还是做了代理,但那是另一个理由)。 ### 坑 2:音频上行"完全无响应" —— 真因是我自己的测试夹具 - **现象**:文本回合一切正常(`setupComplete` + 语音回包),但流式上行真实音频后,服务端**只回了 `setupComplete` 和 `sessionResumptionUpdate`,然后彻底安静**:没有错误、没有关闭、没有 ASR。 - **第一反应(错的)**:怀疑 mimeType、怀疑字段、怀疑模型。于是跑了 5 组对照: | 变体 | 结果 | |---|---| | `audio` + `audio/pcm;rate=16000` | 超时,ASR 空 | | `audio` + `audio/pcm` | 超时 | | 旧字段 `mediaChunks` | 超时 | | 换 `gemini-2.5-flash-native-audio-latest` | **ASR 出来了**(说明格式没问题!),但没回音频 | | 换 `gemini-3.1-flash-live-preview` + 发 `audioStreamEnd` | ✅ 全通 | 第四行其实是关键线索:模型**听见了**,说明上行格式是对的——那"不回话"就不是格式问题,而是"回合没有结束"。 - **真因**:我的测试音频是 `say` 合成后直接切出来的,**结尾没有静音**。而回合结束**完全由服务端 VAD 依据静音判定**;把一整段话灌进去、结尾戛然而止,服务端永远等不到"说完了"。 - **验证**:给同一段音频补 1.5 秒静音后重测 —— `gemini-3.8-live` 立刻正常(ASR + 回话 + 201KB 音频)。再测"无静音 + 不发 end"依旧超时,形成干净的对照。 - **修法(对产品反而是好事)**:真实场景里麦克风是持续流的,孩子说完自然停顿就会触发 VAD,**不需要任何"我说完了"按钮**。同时把 `silenceDurationMs` 从 500ms 放宽到 **700ms**——孩子句中停顿常超过 500ms,会被误判为说完而打断。 - **教训**:**测试夹具要贴近真实。** "无静音音频"是合成测试独有的产物,却让我一度去改一个本来就正确的协议实现。同时也要学会读"部分成功"的信号(第四行 ASR 出来了),它往往直接指向真因。 ### 坑 3(用户实际遇到的):Google 回的是**二进制帧**,我按文本解析 - **现象**:用户在浏览器里"一直显示连接中"。他贴出的诊断日志是决定性的: ``` onopen(握手成功),发送 setup.. setup 已发送,等待 setupComplete.. [4.12s] 收到非 JSON [4.13s] 收到非 JSON ``` - **定位**:握手成功、setup 发出、然后每条消息都"收到非 JSON"——说明消息**到了**,只是解析失败。 - **根因**:Google Live 把 JSON 装在**二进制帧**里回传。前端我写的是 `JSON.parse(ev.data)`;`ev.data` 是 Blob,`JSON.parse(Blob)` 必然抛错。而我的 Node 测试脚本里顺手写了二进制处理,所以**只有浏览器挂、Node 全过**——这也是为什么我一开始完全没往这个方向想。 - **修法**: ```js ws.binaryType = 'arraybuffer'; async function liveFrameText(data) { if (typeof data === 'string') return data; if (data instanceof Blob) return await data.text(); if (data instanceof ArrayBuffer) return new TextDecoder().decode(data); return String(data); } ``` - **教训**:跨运行时(Node ↔ 浏览器)的**数据表示差异**是最隐蔽的一类 bug。同一段协议代码,两边对"帧是文本还是二进制"的默认假设不同,就会一边通一边挂。**凡是 WebSocket,第一件事就是显式设定 `binaryType` 并同时处理两种帧。** ### 坑 4:Cloudflare Worker 把 Blob 字符串化成了 `"[object Blob]"` - **现象**:代理写完后端到端测试超时;加原始帧诊断后看到:帧内容是字符串,字面就是 `[object Blob]`。 - **根因**:Workers 里上游 WS 的 `event.data` 可能是 **Blob**,而 `server.send(blob)` 会被强制 `String()` 化。 - **修法**:转发前统一归一化—— ```js async function toSendable(data) { if (typeof data === 'string') return data; if (data instanceof ArrayBuffer) return data; if (ArrayBuffer.isView(data)) return data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength); if (data && typeof data.arrayBuffer === 'function') return await data.arrayBuffer(); return String(data); } ``` - **修完复测**:`setupComplete` ✅、30 帧、回话 `"Hi there, do you know what the government does to help people in your town?"`、音频 **192,480 字节** ✅ - **教训**:与坑 3 同源——**二进制/文本边界要显式处理,不能依赖隐式转换**。而且这次是"三个运行时"(浏览器 / Worker / Node)各自有不同的默认表示,任何一个环节偷懒都会断链。 ### 坑 5:麦克风挂起、界面无反馈 - **现象**:无头环境里点开始后状态行一直不动。虽然这是环境限制(没有真麦克风),但**静默挂起对真实用户同样是坏体验**:用户不知道是没权限、没设备、还是卡了。 - **修法**:`getUserMedia` 与 `audioWorklet.addModule` 都包一层超时(8s / 5s),并在请求**之前**就先写状态:"🎤 正在请求麦克风权限…";失败给出可操作提示("检查浏览器麦克风权限或设备")。同时调整顺序为**先起麦克风再连 WS**——麦克风失败就不必建立连接,也让状态行先报出真实采样率。 - **附带收益**:这个超时机制让无头测试第一次"跑出了可读的失败信息",否则我连它卡在哪一步都不知道。 ### 坑 6:没有可观测性,只能靠猜 - 早期状态只有一句"正在连接…",我远程根本无法判断卡在哪:是权限?是握手?是 setup 被拒? - **修法**:面板底部加**带时间戳的诊断日志**,逐点留痕:打开面板(模型 / 可用连接方式 / Key 长度)→ 请求麦克风 → 麦克风就绪(实际采样率 + 重采样比)→ 尝试哪种连接方式 → `onopen` → setup 已发送 → `setupComplete` → 开始上行音频 → 收到首帧语音 → `onclose(code)`。 - **回报**:正是这套日志让用户一眼贴出"收到非 JSON",坑 3 当场定位。**诊断能力本身就是功能的一部分**——尤其在"开发者无法复现用户环境"的时候。 ### 坑 7:与并行会话在同一仓库里"撞车" 这个仓库同时有另一个会话在开发(反馈墙 / 背景图片 / 共现词网),而且它在持续提交。 - **撞车 1**:我把语音模块命名为 `31-voice-chat.js`,而对方已占用 `31-feedback.js`。→ 改名为 `34-`,实时版用 `35-`。 - **撞车 2**:对方的烟测断言写死"主单词操作行 = 6 个图标按钮",我加了 🎙 变 7、再加 ⚡ 变 8,连续两次把烟测跑红。→ 同步更新断言。**这类"数量断言"在共享仓库里天然易碎,更稳的写法是断言"包含哪些元素"而不是"恰好几个"。** - **撞车 3**:多次出现 `file changed since it was read`——对方在我读文件之后改了同一个文件。→ 每次编辑前重读;编辑工具的这个保护机制实际上救了并行协作。 - **纪律**:不碰对方正在改的文件;新模块一律**追加到末尾**;改动尽量集中在自己的新文件里;每次提交/部署前跑全量门禁(因为部署会把对方已提交的代码一起带上线)。 ### 坑 8:模块边界静态检查拦下了重名 - 项目有严格规则:IIFE 包装块内的私有名不得被外部裸引用。我用了 `let messages`,而 `04/14/24` 等模块里有同名局部变量(函数参数),检查器判定"私有名被外部引用"直接报错。 - **修法**:重命名为 `voiceMsgs`。**没有去改别人的文件**——这类检查器的价值就在于逼你选一个不会污染的命名。规则看似苛刻,实际是在共享作用域(单文件拼接产物)里保命的护栏。 ### 坑 9:密钥落点 - 浏览器直连必须把 Google API Key 放在本机(`dict_live_config`)。这在"设备多、还想不翻墙"的场景下不可接受。 - **修法(见第六节)**:改成 Worker 中转,Key 存 Worker secret,**浏览器不再持有密钥**;前端仍保留"直连 + 本机 Key"作为兜底路径。 ### 坑 10:Service Worker 缓存让用户看不到新版本 - 页面导航是网络优先,但预缓存的 manifest 与静态资源是缓存优先。 - **修法**:改动涉及数据口径变化时**同步升 SW 版本号**(本次 v3 → v4),并明确告知用户强刷(`Cmd+Shift+R`)。这类"代码明明发了、用户却看到旧的"的问题,几乎每次都浪费一轮沟通。 ### 坑 11:预期管理——"延迟极低"没那么低 - 实测(说完 → 首帧语音):第 1 轮 **1.76s**、第 2 轮 **1.24s**,其中包含 VAD 静音判定窗口(500ms)。更早一次测到 2.60s,说明首轮与网络波动都会影响。 - 这个量级"能对话",但不是宣传语里的"极低"。而且加大静音窗口(照顾孩子停顿)会再增加每轮延迟——**这是体验上的直接取舍**,必须如实告诉使用者,而不是照抄营销话术。 --- ## 六、Cloudflare 代理:让不翻墙的设备也能用 **为什么必须做**:部分设备/网络无法直连 `generativelanguage.googleapis.com`(国内常见),而 Cloudflare 边缘可以。用户明确要求"不翻墙也能用"。这一步也顺带把密钥从浏览器里拿走了。 **实现**(复用已有的 `<你的站点>/api/uapi/*` 路由,**无需改 wrangler 路由配置**): ```js async function handleLive(request, env) { const pair = new WebSocketPair(); const client = pair[0], server = pair[1]; server.accept(); // 与已有的 Edge TTS 出站 WS 同一套写法:Workers 的 fetch 不接受 wss://,用 https + Upgrade 头 const resp = await fetch(GOOGLE_WS_URL + '?key=' + env.<密钥名>, { headers: { Upgrade: 'websocket' } }); const upstream = resp.webSocket; upstream.accept(); upstream.addEventListener('message', async (ev) => server.send(await toSendable(ev.data))); server.addEventListener('message', async (ev) => upstream.send(await toSendable(ev.data))); // 双向 close/error 互相传导,避免一端断了另一端悬挂 return new Response(null, { status: 101, webSocket: client }); } ``` **几个关键细节** - 上游失败时**不要**返回 101:要在 `await fetch` 之后确认 `resp.webSocket` 存在,否则返回 502 并把上游错误正文带上——这样浏览器侧会得到一个明确的握手失败,而不是"连上了但没反应"。 - `close` 与 `error` 要**双向传导**,否则一端断开后另一端会一直挂着,表现为"卡住"。 - 密钥管理:`npx wrangler secret put <密钥名>`(值不落盘、不进仓库、不进浏览器)。 - 免费版的 WS 连接数与时长约束需要观察;长时间连续对话可能要改成带 `sessionResumption` 的短会话。 **前端的连接顺序**(自动降级,日志会写明最终用了哪条): ```js function liveEndpoints() { const list = []; const isLocal = ['127.0.0.1', 'localhost'].includes(location.hostname); if (!isLocal) list.push({ name: 'Cloudflare 代理', url: 'wss://' + location.host + '/api/uapi/live' }); const cfg = liveCfg(); if (cfg.key) list.push({ name: '直连 Google', url: WS_BASE + '?key=' + ... }); return list; } ``` 逐个尝试,12 秒未 `onopen` 就换下一种;`setupComplete` 之前断开也会换。 **端到端验证**(Node 直连代理,不带 key):`onopen 5.18s` → `setupComplete` → 30 帧 → 回话文本 + **192,480 字节音频**。 ### 免费版限制逐条对照与对策 | 限制 | 免费版规定 | 是否构成约束 | 我们的做法 | |---|---|---|---| | 计费方式 | 每个 WS 连接计 **1 次请求**,连接内消息不计费 | 否 | 一次对话 = 1 次请求 | | 每日请求 | **100,000 次 / 天**(UTC 午夜重置) | 否,个人使用差几个数量级 | — | | CPU 时间 | **10 ms / 请求** | 否,但必须守住 | 转发只做类型判断 + `send`:不解析 JSON、不做 base64、不遍历数据;每条消息 CPU 远低于 1ms | | 单帧大小 | 1 MB | 否 | 上行每帧 4,096 字节 PCM → base64 约 5.4KB,占上限 **0.5%** | | **空闲超时** | 约 **100 秒**无数据即被边缘关闭 | **是——唯一真正的风险** | 见下 | | 并发连接 | 1,000 / Worker | 否 | — | | 执行超时 | 30 秒 / 请求 | 边界情况 | `handleLive` 先 `await` 上游握手再返回 101,实测 2–5 秒 | | 内存 | 128 MB | 否 | 不缓存任何消息,纯对拷 | **唯一需要真正处理的:空闲超时。** 正常对话时**根本不会空闲**——麦克风每 128ms 就上行一帧(约 7.8 帧/秒)。孩子说话、AI 说话、甚至双方都不说话的间隙,音频流都在持续上行。所以这个 100 秒超时在正常使用下触发不了。 它只在**麦克风停摆**时才会咬人,典型场景是:标签页被切到后台导致 AudioContext 被挂起、麦克风设备被拔掉、权限被收回。此时连接会在约 100 秒后被静默切断,而用户还在对着空气说话——这是最糟的失败形态。 因此加了两道保险: 1. **保活心跳**:45 秒没有上行就补发 **20ms 静音**(320 采样)。协议合法、不足以触发 VAD,只为把连接"焐热"。 2. **麦克风停摆告警**:12 秒收不到麦克风数据就明确提示"麦克风似乎停止输出了(页面被切到后台?)",而不是让用户以为是自己没说话。 **为什么不做"每 30 秒定时 ping"**:那样会在正常对话里往模型注入无意义的音频,也可能干扰 VAD。我们的做法是"**仅在真的空闲时才发**"——正常使用下这条路径一次都不会触发。**心跳是安全网,不是常规路径。** **另一条被排除的方案:Durable Objects。** DO 的 WebSocket 按墙钟时间计费(连接活着就计费,哪怕没有消息),免费额度有限;而我们的场景是"一个人偶尔练口语",Worker 纯转发完全够用,也不需要 DO 的房间隔离能力。**选型要匹配真实负载,而不是匹配最坏情况。** --- ## 七、验证体系:我怎么证明它真的能用 分四层,每层都有明确手段,并且**明确知道哪一层没覆盖**: | 层 | 手段 | 覆盖到什么 | |---|---|---| | 协议层 | Node 直连 Google WS,发文本回合 | 端点、setup schema、模型可用性、音频回包 ✅ | | 音频层 | `say` 合成 → `afconvert` 转 16k mono PCM16 → 按 128ms 流式上行 | 上行格式、VAD 回合判定、ASR、端到端延迟 ✅ | | 代理层 | Node 连 `wss://<你的站点>/api/uapi/live` | Worker 中转、secret、双向帧 ✅ | | UI 层 | 无头 Chrome + `--use-fake-device-for-media-stream` + 假 AI 端点 | 面板、设置表单、失败路径、超时反馈、按钮态 ✅ | | 门禁 | `make check` / `make test` / `make smoke` | 语法、模块边界、词形与坐标回归、载入期零报错 ✅ | **明确没覆盖的**:真实浏览器 + 真实麦克风的完整闭环。无头环境给不了真麦克风,所以"采集 → 上行 → 听到回复"这一段只能由用户在真机上确认——这也是为什么我把**诊断日志**做进面板:把"我无法自测的部分"变成"用户一眼可读的证据"。 **一次失败的对照也值得记**:`afconvert -f RAW -d LEI16@16000` 直接报 `ExtAudioFileCreateWithURL failed ('typ?')`(RAW 容器不支持),改成先转 WAVE 再用 Python `wave` 模块剥头取 PCM。工具链的小坑,但会白白吃掉半小时。 **另一条经验**:先用"文本回合"验证协议(`clientContent`),再验证音频回合。文本回合把变量降到最少(不涉及采集、不涉及 VAD),一旦文本通、音频不通,问题范围立刻缩小到"音频格式或回合判定"。 --- ## 八、几个关键工程取舍 1. **不做替换,做叠加**:⚡ 首选 + 🎙 兜底。任何一个依赖失效,功能都还在。 2. **派生表优先于重跑管线**:音频版索引没有塞进文章分片(那要整仓重建 20–40 分钟),而是做成按 `aid` 的派生表 `article_audio.json`(1.3MB,一次扫描 17 万文件约 1 分钟),与既有的 `corpus_infl.json` 同形态。同样的思路也用在这里:**Live 完全没有动语料管线**。 3. **常量外置**:采样率(16k/24k)、模型名、静音窗口、分片大小全部放在模块顶部——这些是"最可能需要按实测调整"的值,不该散落在逻辑里。 4. **保守的协议使用**:没核实的嵌套结构(`speechConfig`)一律不发。 5. **诊断优先**:宁可多写十几行日志,也不要让失败只有一句"连接失败"。 6. **提示词即产品**:少儿场景下,约束比能力更重要(详见第十三节)。 7. **单文件产物的约束要照顾**:AudioWorklet 需要独立脚本,用 Blob URL 内联,避免破坏"dict.html 单文件"的构建形态。 --- ## 九、遗留问题 1. **密钥轮换**:本功能把密钥放在服务端(Worker secret),因此轮换不需要改代码——建议定期在 AI Studio 重建一次,再重跑一次 secret 写入命令即可。 2. **会话时长与续接**:Live 会话有最长时长限制,长对话需要 `sessionResumption`(协议已支持,前端未接)。 3. **成本与额度**:曾流传的"每天 400 万次免费"**未能核实**(沙箱联网受限,无法查证)。Live 音频按 token 计费,请以 AI Studio 控制台当前配额为准,不要按该数字做规划。 4. **儿童语音识别准确率**:这是效果的最大变量。可用手段:把识别结果先显示出来让孩子确认、用 `inputAudioTranscription` 做可视化、必要时换更高精度模型。 5. **端到端延迟**:1.2–2.6s 仍有优化空间(缩短静音窗口、减少中转跳数、换更快的 Live 模型)。 6. **Cloudflare 免费版对 WS 的连接数/时长约束**:CPU 10ms、并发 1000、每日 10 万请求对我们都不构成约束;唯一真实风险是**约 100 秒空闲被静默切断**,已用"仅在空闲时补发 20ms 静音"的保活 + 麦克风停摆告警覆盖(见第六节)。仍建议观察:长会话下的边缘行为、以及标签页长时间后台时的实际表现。 7. **真实麦克风闭环**:需用户在真机确认一次。 8. **多语言/中英混说**:目前提示词要求"孩子说中文时用英文回并附一句中文提示",但没有做语言检测与切换的显式处理。 --- ## 十、可迁移的教训(浓缩版) 1. **测试工具本身要先被验证。** 坑 1:没有对照组的失败观测,会把你带向完全错误的架构结论。 2. **测试夹具要贴近真实。** 坑 2:一个"结尾没有静音"的合成音频,差点让我去改一个正确的协议实现。 3. **二进制/文本边界必须显式处理。** 坑 3、4:同一段协议代码在 Node 通、在浏览器挂;在浏览器通、在 Worker 挂——都栽在隐式转换上。 4. **挂起必须有超时和反馈。** 坑 5:静默等待是最糟的失败形态。 5. **可观测性优先于猜测。** 坑 6:一行带时间戳的日志,胜过十轮远程猜测。 6. **共享仓库要有协作纪律。** 坑 7:不碰别人正在改的文件、新模块追加到末尾、少写"恰好几个"这类易碎断言。 7. **协议集成先去拿权威文本。** 第三节:curl 抓 discovery 文档的十分钟,省掉了数小时试错。 8. **关键约束要写进提示词。** 少儿场景里,"不说什么"比"能说什么"更决定可用性。 9. **先降变量再定位。** 用文本回合验证协议,再用音频回合验证采集与 VAD;不要一上来就端到端调。 10. **诚实记录自己的错误判断。** 本文里坑 1、坑 2 都是我自己的误判;把它们写下来,比只记"最终方案"更有价值。 --- ## 十一、复现手册(从零把这条链路跑起来) **第一步:确认 key 与模型可用** ```bash curl -s "https://generativelanguage.googleapis.com/v1beta/models?key=$GK&pageSize=200" \ | python3 -c "import json,sys; d=json.load(sys.stdin); print([m['name'] for m in d['models'] if 'bidiGenerateContent' in (m.get('supportedGenerationMethods') or [])])" ``` 期望:列出 `models/gemini-3.8-live` 等 8 个模型。若报 403,说明 key 无效或未开通。 **第二步:文本回合验证协议(不含音频)** 连接 WS → 发 `setup` → 等 `setupComplete` → 发 `clientContent` 文本 → 收 `serverContent` 音频 + 字幕。 注意:**接收端必须按二进制处理**(坑 3)。 **第三步:音频回合验证(关键:音频结尾要带静音)** ```bash say -o /tmp/probe.aiff "Hello! I am nine years old and I want to talk about government." afconvert -f WAVE -d LEI16@16000 -c 1 /tmp/probe.aiff /tmp/probe.wav python3 -c " import wave; w=wave.open('/tmp/probe.wav','rb'); pcm=w.readframes(w.getnframes()) open('/tmp/probe.pcm','wb').write(pcm + b'\x00'*(16000*2*3//2))" # 追加 1.5s 静音 ``` 然后按 128ms/包流式上行 `realtimeInput.audio`。**不发 `audioStreamEnd` 也应该能收到回话**(靠 VAD)。 **第四步:部署代理** ```bash cd tools/worker npx wrangler secret put <密钥名> # 粘贴 key npx wrangler deploy ``` 验证:Node 连 `wss://<你的站点>/api/uapi/live`(不带 key),应能收到 `setupComplete`。 **第五步:前端门禁与发布** ```bash make check && make test && make smoke && make deploy && make verify ``` **第六步:真机确认** 强刷页面 → 查词 → ⚡ → ▶ 开始对话 → 允许麦克风 → 说英文。 若失败,读面板底部日志:它会指出是权限、握手、还是 setup 阶段的问题。 --- ## 十二、协议消息实例(实测可用) **setup(客户端第一条,也是唯一一条)** ```json { "setup": { "model": "models/gemini-3.8-live", "generationConfig": { "responseModalities": ["AUDIO"] }, "systemInstruction": { "parts": [{ "text": "…少儿陪练提示词…" }] }, "inputAudioTranscription": {}, "outputAudioTranscription": {}, "realtimeInputConfig": { "activityHandling": "START_OF_ACTIVITY_INTERRUPTS", "automaticActivityDetection": { "prefixPaddingMs": 200, "silenceDurationMs": 700 } } } } ``` **上行音频(持续)** ```json { "realtimeInput": { "audio": { "data": "<base64 PCM16>", "mimeType": "audio/pcm;rate=16000" } } } ``` **下行(服务端)** ```json { "serverContent": { "modelTurn": { "parts": [ { "inlineData": { "data": "<base64 PCM16 24kHz>" } } ] }, "inputTranscription": { "text": "孩子说的话" }, "outputTranscription": { "text": "模型回的话" }, "interrupted": false, "turnComplete": true } } ``` **注意**:`systemInstruction` 用的是 `Content` 形状(`{parts:[{text}]}`),实测被服务端接受;空对象 `{}` 即可开启输入/输出字幕。 --- ## 十三、提示词设计:约束比能力重要 少儿场景下,我把提示词写成"六条规则",每条都对应一个真实风险: 1. **每轮 1~2 句简单英文(A2 以内),必须以一个简单问题结尾。** —— 防止模型长篇大论。孩子听不懂长句,也不会回应;"必须反问"是把话头强制交回给孩子,保证这是对话而不是听课。 2. **尽量自然地把目标词用进句子里。** —— 这是整个功能的立身之本:孩子在真实语境里反复听到目标词。 3. **孩子说错时:先用短词肯定(Good try!),再用 `We say: ...` 纠正,然后接着聊。** —— 直接纠错会让孩子不敢开口。先肯定、再给正确说法、然后立刻回到话题,是口语练习里最有效的纠错节奏。 4. **孩子说中文时:用简单英文回应,并附一句很短的中文提示。** —— 孩子卡住时往往会切回中文。完全无视会让他挫败,完全顺着他讲中文又失去练习意义,所以"英文为主 + 一句中文托底"。 5. **不问姓名/年龄/学校/住址等个人信息;不聊暴力、恋爱、恐怖、政治;跑题就温和拉回目标词。** —— 这是**能不能给孩子用**的前提。开放式的自由聊天对儿童既不安全也不聚焦。 6. **不要 Markdown、列表或 emoji;语速放慢、吐字清楚。** —— 输出会直接被 TTS 朗读,任何符号都会变成噪音;这条是"可朗读性"的工程约束。 **一个实现细节**:提示词里会把该词在词典里的释义(`Storage.cacheGet('meanings', word).senses`)一并注入,并明确写"供你参考,不要照读"——避免模型把释义当台词念出来。 --- ## 十四、延迟与成本 **实测延迟(说完 → 首帧语音)** | 轮次 | 延迟 | 说明 | |---|---|---| | 第 1 轮 | 1.76s | 含会话建立与模型预热影响 | | 第 2 轮 | 1.24s | 稳定态 | | 另一次首轮 | 2.60s | 网络波动明显 | 延迟构成:**服务端 VAD 静音判定窗口(当时 500ms)+ 模型推理 + 首帧音频生成 + 网络往返**。 所以"降低延迟"最直接的手段是缩短静音窗口——但代价是更容易打断孩子。目前选择 700ms 是偏体验的取舍。 **成本与额度** - Live 音频按 token 计费,明显高于纯文本调用。 - 曾流传的"每天 400 万次免费"我**无法核实**(沙箱联网受限),不应作为规划依据。请以 AI Studio 控制台显示的当前配额为准。 - 一个务实的做法:把 Live 定位为"想练口语时的强化模式",日常释义/例句仍走便宜的文本接口。 **代理的额外开销** - 多一跳(浏览器 → Cloudflare → Google),实测 `onopen` 约 2–5 秒(含 Cloudflare 冷启动与到 Google 的连接建立)。 - 好处是**换来了"不翻墙可用"与"密钥不进浏览器"**,这个交换是划算的。 --- ## 附一:原文阅读(同一会话的另一块工作) **做了什么**:例句工具条加「📜 原文」,点开该句出处的整篇《经济学人》文章,自动高亮当前句;面板内可复制整篇、AI 逐段翻译、AI 解读,正文里选中单词直接转查询。 **数据实现** - 词分片条目从 `[句, yyyymm, 栏目索引]` 升级为 `[句, yyyymm, 栏目索引, aid, q]`。 - 新增文章分片:260 片 / 619MB,按 `aid // 500` 算术定位,片内按 `aid` 取文章。 - 新增回归门禁 `article_check.py`:探针词的坐标必须能定位到文章分片且句子逐字命中——**两条数据流之间的连接是最容易悄悄断掉的地方**。 **坑** - **`aid` 是位置编号**,靠文件枚举顺序分配,因此新增文章会导致整条编号空间平移,无法做"只处理增量"。这是后来把音频版做成派生表的直接原因。 - **复用学习模式的按钮样式导致功能键换行**:`.study-actions` 自带 `min-width:110px` + `flex-wrap:wrap`,四个键就要 470px;更糟的是窄屏下 flex 把按钮压到比文字还窄,中文变成"一字一行"竖排,而按钮写死 `height:28px`,多出的行被裁掉——看起来就是"显示不开了"。修法:按钮组允许换行 + 按钮自身 `nowrap` + `flex-shrink:0`,窄屏把文字标签收成图标。 - **动态改 `btn.textContent` 会抹掉内部 `<span class="ar-label">`**,导致窄屏折叠逻辑失效。这个 bug 是靠 DOM 几何探针发现的("四个按钮里只有两个还能收起"),肉眼很难看出来。修法是写一个小工具函数重建 `图标 + 标签` 结构,所有状态回写都走它。 - **版式还原**:按 economist.com 的通行做法做——纸面底、红底白字 E 标识、红色栏目眉、无衬线大标题、衬线正文(Georgia 兜底)、首字下沉、文末红方块。顺带发现深色纸面上品牌红小字只有 **3.71:1** 对比度,于是把"图形红"与"文字红"拆成两个变量(文字在深色下提亮到 `#ff6157`)。 - **可回退性**:全文层是独立的版本目录,因此"是否分发全文"是一个可以随时收回的开关——改 manifest 指针加前端入口,即可退回只给"例句 + 有限上下文"的粒度。 --- ## 附二:音频版链接(同一会话的另一块工作) **做了什么**:有音频版的文章,在栏目眉右侧给一个「🔊 音频版」入口,点开即听原声。 **数据来源与覆盖率** - 存档 HTML 里嵌有每篇文章的 MP3:`<audio src="https://www.economist.com/content-assets/audio/…mp3">`;实测该地址**公开可放、无需鉴权**(HTTP 200、2.6MB、`cache-control: public, max-age=31536000`)。 - 覆盖率 **12,718 / 129,514 篇(9%)**,集中在 2019 年后(2023+ 为主);更早的原生存档页不含 `<audio>`。 **坑** - 音频文件名是**音频版期内的轨道名**(如 `018 United States - Bad guys-<hash>.mp3`),与网页标题完全不同,必须用 `(发行日期, 栏目, slug 还原标题)` 三者对齐;实测 12,379 个键**零冲突**,可以安全使用。 - 派生表按 `aid` 索引,而 `aid` 会随语料重建变化——所以请求时必须带上文章分片版本号做缓存键(`article_audio.json?v=v<hash>`),否则 Service Worker 的缓存优先策略会让前端拿到上一版错配表,把 A 文章的音频挂到 B 文章上。 - 只有 9% 的覆盖率意味着**大多数文章没有入口**,这必须如实呈现(前端在无音频时不渲染任何占位,避免"点了没反应")。 --- ## 结语 这个功能的技术难点不在"调用一个 API",而在四件事: **一是把不确定性挡在门外**——协议不靠记忆靠文档,模型可用性不靠假设靠一条 curl。 **二是把不可见变成可见**——诊断日志、字幕、几何探针、原始帧打印,都是把"猜"换成"读"。 **三是把失败设计进产品**——兜底入口、超时反馈、自动降级、保守的协议子集。 **四是诚实**——延迟不是"极低"、覆盖率只有 9%、额度数字未能核实、真实麦克风尚未真机验证。把这些写清楚,比把功能说得漂亮更有价值。 而最值得记住的一条:**这一路上最大的两个坑,都是我自己造成的**——一次是测量工具没做对照,一次是测试夹具不真实。它们提醒我,排障时第一个该怀疑的对象,往往是自己的验证手段。
配图 (可多选)
选择新图片文件或拖拽到此处
标签
更新文章
删除文章