实时语音对话功能:实现回顾与踩坑记录(Gemini Live + Cloudflare 代理)

实时语音对话功能:实现回顾与踩坑记录

目标:让孩子在查完一个单词后,能就这个单词跟 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)、audiovideotextactivityStartactivityEndaudioStreamEnd
  • BidiGenerateContentSetup(discovery 文档核实):modelgenerationConfigsystemInstructiontoolsrealtimeInputConfiginputAudioTranscriptionoutputAudioTranscriptionsessionResumptioncontextWindowCompressionhistoryConfig
  • RealtimeInputConfigturnCoverageautomaticActivityDetectionactivityHandling
  • 打断语义activityHandling = START_OF_ACTIVITY_INTERRUPTS(默认,即"抢话即打断")/ NO_INTERRUPTION;而 activityStart/activityEnd 仅在关闭服务端 VAD 时才允许发送
  • VAD 参数automaticActivityDetection.prefixPaddingMs / silenceDurationMs
  • 该 key 可用的 Live 模型(58 个可见模型里有 8 个支持 bidiGenerateContent):gemini-3.8-livegemini-3.8-live-extended-thinkinggemini-3.1-flash-live-previewgemini-2.5-flash-native-audio-latest(及 09/12 预览版)、gemini-3.5-transcribe-livegemini-3.5-live-translate-previewgemini-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 → 用 AudioBufferSourceNodeplayAt 时间戳顺序排队播放,保证连续不断句。
  • 维护一个"下一段该在什么时候播"的时间轴:若上一段已播完(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 + 语音回包),但流式上行真实音频后,服务端只回了 setupCompletesessionResumptionUpdate,然后彻底安静:没有错误、没有关闭、没有 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 全过——这也是为什么我一开始完全没往这个方向想。

  • 修法

    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:麦克风挂起、界面无反馈

  • 现象:无头环境里点开始后状态行一直不动。虽然这是环境限制(没有真麦克风),但静默挂起对真实用户同样是坏体验:用户不知道是没权限、没设备、还是卡了。
  • 修法getUserMediaaudioWorklet.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 并把上游错误正文带上——这样浏览器侧会得到一个明确的握手失败,而不是"连上了但没反应"。
  • closeerror双向传导,否则一端断开后另一端会一直挂着,表现为"卡住"。
  • 密钥管理: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.18ssetupComplete → 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 秒 / 请求 边界情况 handleLiveawait 上游握手再返回 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 与模型可用

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. 每轮 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%、额度数字未能核实、真实麦克风尚未真机验证。把这些写清楚,比把功能说得漂亮更有价值。

而最值得记住的一条:这一路上最大的两个坑,都是我自己造成的——一次是测量工具没做对照,一次是测试夹具不真实。它们提醒我,排障时第一个该怀疑的对象,往往是自己的验证手段。