实时语音对话功能:实现回顾与踩坑记录
目标:让孩子在查完一个单词后,能就这个单词跟 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 抓官方文档:
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、audioStreamEndBidiGenerateContentSetup(discovery 文档核实):model、generationConfig、systemInstruction、tools、realtimeInputConfig、inputAudioTranscription、outputAudioTranscription、sessionResumption、contextWindowCompression、historyConfigRealtimeInputConfig: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 命令——而不是一个需要问人的问题。
四、实现:音频管线
上行(孩子说)
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-latestASR 出来了(说明格式没问题!),但没回音频 换 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 全过——这也是为什么我一开始完全没往这个方向想。 -
修法:
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()化。 -
修法:转发前统一归一化——
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 路由配置):
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的短会话。
前端的连接顺序(自动降级,日志会写明最终用了哪条):
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 秒后被静默切断,而用户还在对着空气说话——这是最糟的失败形态。
因此加了两道保险:
- 保活心跳:45 秒没有上行就补发 20ms 静音(320 采样)。协议合法、不足以触发 VAD,只为把连接"焐热"。
- 麦克风停摆告警: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),一旦文本通、音频不通,问题范围立刻缩小到"音频格式或回合判定"。
八、几个关键工程取舍
- 不做替换,做叠加:⚡ 首选 + 🎙 兜底。任何一个依赖失效,功能都还在。
- 派生表优先于重跑管线:音频版索引没有塞进文章分片(那要整仓重建 20–40 分钟),而是做成按
aid的派生表article_audio.json(1.3MB,一次扫描 17 万文件约 1 分钟),与既有的corpus_infl.json同形态。同样的思路也用在这里:Live 完全没有动语料管线。 - 常量外置:采样率(16k/24k)、模型名、静音窗口、分片大小全部放在模块顶部——这些是"最可能需要按实测调整"的值,不该散落在逻辑里。
- 保守的协议使用:没核实的嵌套结构(
speechConfig)一律不发。 - 诊断优先:宁可多写十几行日志,也不要让失败只有一句"连接失败"。
- 提示词即产品:少儿场景下,约束比能力更重要(详见第十三节)。
- 单文件产物的约束要照顾:AudioWorklet 需要独立脚本,用 Blob URL 内联,避免破坏"dict.html 单文件"的构建形态。
九、遗留问题
- 密钥轮换:本功能把密钥放在服务端(Worker secret),因此轮换不需要改代码——建议定期在 AI Studio 重建一次,再重跑一次 secret 写入命令即可。
- 会话时长与续接:Live 会话有最长时长限制,长对话需要
sessionResumption(协议已支持,前端未接)。 - 成本与额度:曾流传的"每天 400 万次免费"未能核实(沙箱联网受限,无法查证)。Live 音频按 token 计费,请以 AI Studio 控制台当前配额为准,不要按该数字做规划。
- 儿童语音识别准确率:这是效果的最大变量。可用手段:把识别结果先显示出来让孩子确认、用
inputAudioTranscription做可视化、必要时换更高精度模型。 - 端到端延迟:1.2–2.6s 仍有优化空间(缩短静音窗口、减少中转跳数、换更快的 Live 模型)。
- Cloudflare 免费版对 WS 的连接数/时长约束:CPU 10ms、并发 1000、每日 10 万请求对我们都不构成约束;唯一真实风险是约 100 秒空闲被静默切断,已用"仅在空闲时补发 20ms 静音"的保活 + 麦克风停摆告警覆盖(见第六节)。仍建议观察:长会话下的边缘行为、以及标签页长时间后台时的实际表现。
- 真实麦克风闭环:需用户在真机确认一次。
- 多语言/中英混说:目前提示词要求"孩子说中文时用英文回并附一句中文提示",但没有做语言检测与切换的显式处理。
十、可迁移的教训(浓缩版)
- 测试工具本身要先被验证。 坑 1:没有对照组的失败观测,会把你带向完全错误的架构结论。
- 测试夹具要贴近真实。 坑 2:一个"结尾没有静音"的合成音频,差点让我去改一个正确的协议实现。
- 二进制/文本边界必须显式处理。 坑 3、4:同一段协议代码在 Node 通、在浏览器挂;在浏览器通、在 Worker 挂——都栽在隐式转换上。
- 挂起必须有超时和反馈。 坑 5:静默等待是最糟的失败形态。
- 可观测性优先于猜测。 坑 6:一行带时间戳的日志,胜过十轮远程猜测。
- 共享仓库要有协作纪律。 坑 7:不碰别人正在改的文件、新模块追加到末尾、少写"恰好几个"这类易碎断言。
- 协议集成先去拿权威文本。 第三节:curl 抓 discovery 文档的十分钟,省掉了数小时试错。
- 关键约束要写进提示词。 少儿场景里,"不说什么"比"能说什么"更决定可用性。
- 先降变量再定位。 用文本回合验证协议,再用音频回合验证采集与 VAD;不要一上来就端到端调。
- 诚实记录自己的错误判断。 本文里坑 1、坑 2 都是我自己的误判;把它们写下来,比只记"最终方案"更有价值。
十一、复现手册(从零把这条链路跑起来)
第一步:确认 key 与模型可用
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)。
第三步:音频回合验证(关键:音频结尾要带静音)
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)。
第四步:部署代理
cd tools/worker
npx wrangler secret put <密钥名> # 粘贴 key
npx wrangler deploy
验证:Node 连 wss://<你的站点>/api/uapi/live(不带 key),应能收到 setupComplete。
第五步:前端门禁与发布
make check && make test && make smoke && make deploy && make verify
第六步:真机确认
强刷页面 → 查词 → ⚡ → ▶ 开始对话 → 允许麦克风 → 说英文。
若失败,读面板底部日志:它会指出是权限、握手、还是 setup 阶段的问题。
十二、协议消息实例(实测可用)
setup(客户端第一条,也是唯一一条)
{ "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 }
}
} }
上行音频(持续)
{ "realtimeInput": { "audio": { "data": "<base64 PCM16>", "mimeType": "audio/pcm;rate=16000" } } }
下行(服务端)
{ "serverContent": {
"modelTurn": { "parts": [ { "inlineData": { "data": "<base64 PCM16 24kHz>" } } ] },
"inputTranscription": { "text": "孩子说的话" },
"outputTranscription": { "text": "模型回的话" },
"interrupted": false,
"turnComplete": true
} }
注意:systemInstruction 用的是 Content 形状({parts:[{text}]}),实测被服务端接受;空对象 {} 即可开启输入/输出字幕。
十三、提示词设计:约束比能力重要
少儿场景下,我把提示词写成"六条规则",每条都对应一个真实风险:
- 每轮 1~2 句简单英文(A2 以内),必须以一个简单问题结尾。
—— 防止模型长篇大论。孩子听不懂长句,也不会回应;"必须反问"是把话头强制交回给孩子,保证这是对话而不是听课。 - 尽量自然地把目标词用进句子里。
—— 这是整个功能的立身之本:孩子在真实语境里反复听到目标词。 - 孩子说错时:先用短词肯定(Good try!),再用
We say: ...纠正,然后接着聊。
—— 直接纠错会让孩子不敢开口。先肯定、再给正确说法、然后立刻回到话题,是口语练习里最有效的纠错节奏。 - 孩子说中文时:用简单英文回应,并附一句很短的中文提示。
—— 孩子卡住时往往会切回中文。完全无视会让他挫败,完全顺着他讲中文又失去练习意义,所以"英文为主 + 一句中文托底"。 - 不问姓名/年龄/学校/住址等个人信息;不聊暴力、恋爱、恐怖、政治;跑题就温和拉回目标词。
—— 这是能不能给孩子用的前提。开放式的自由聊天对儿童既不安全也不聚焦。 - 不要 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%、额度数字未能核实、真实麦克风尚未真机验证。把这些写清楚,比把功能说得漂亮更有价值。
而最值得记住的一条:这一路上最大的两个坑,都是我自己造成的——一次是测量工具没做对照,一次是测试夹具不真实。它们提醒我,排障时第一个该怀疑的对象,往往是自己的验证手段。