主题
流式会话与定稿引擎
本文引用的源码文件
本文引用的文件
引言
语音链路分两段:流式(草稿)与定稿。流式让用户边说边看到文字,定稿在松手后用整段音频重转一遍拿到更高精度结果。两段用的不是同一个模型:online 包负责低延迟草稿,offline 包负责终稿。
本文说明这条链路上最容易被忽视的三件事:WebSocket 的鉴权与心跳、会话资源的收敛路径、以及为什么 offline 推理必须离开主线程。
目录
通道建立
升级请求由 server.on('upgrade') 统一处理,/ws/asr/:sessionId 与 /ws/exec 走同一套鉴权:
GET /ws/asr/<sessionId> (Upgrade: websocket)
Authorization: Bearer <token> ← 优先
或 ?token=<token> ← 回退(浏览器 WebSocket API 无法自定义 Header)三道关卡依次是:鉴权失败 → HTTP/1.1 403;依赖不可用 → 503 Service Unavailable(客户端据此引导用户去安装);通过后 handleUpgrade。
通道建立后每 30 秒 ping 一次,两拍未回 pong 视为半开连接,直接 terminate,走正常断开收敛路径。query token 可能被代理日志或 Referer 记录,因此任何日志都不写入 token 内容。
帧协议
上行:
| 帧 | 内容 |
|---|---|
| 二进制帧 | PCM16LE / 单声道 / 16kHz,每帧约 100ms;单帧上限 320KB(10 秒),超过视为异常客户端 |
{"type":"start","language":"auto"} | 开始会话,language 缺省沿用配置 |
{"type":"stop"} | 停止采集并发起定稿 |
{"type":"cancel"} | 放弃本次会话,不产生结果 |
下行:
| 帧 | 含义 |
|---|---|
{"type":"ready","draft":bool,"finalize":bool} | 会话就绪;draft=false 表示没有可用的流式模型,只等 stop 定稿 |
{"type":"partial","text":"…"} | 流式草稿,服务端节流 150ms |
{"type":"endpoint","text":"…"} | 检测到句尾,草稿已提交为稳定片段,后续不再回改 |
{"type":"final","text":"…","fallback":bool} | 定稿;fallback=true 表示退化为流式文本 |
{"type":"error","code":"…","error":"…"} | busy / model_missing / internal / rss_pressure |
partial 的节流是刻意的:不节流会把 WebSocket 打满,逐帧刷新肉眼也读不及;150ms 既连贯又不浪费带宽。endpoint 与 partial 的区别在于可回改性——稳定片段只追加,不会因为后文修正而整句重排。
会话生命周期与资源回收
所有路径最终都收敛到 release():
| 触发 | 说明 |
|---|---|
| 连接关闭(未 stop) | 用户关掉侧边栏或网络断开 |
| 空闲 30 秒 | SESSION_IDLE_MS,收不到任何帧即释放 |
| 总时长 5 分钟 | MAX_RECORDING_MS,超时结束并定稿 |
| 并发超限 | MAX_SESSIONS 默认 1,第二个请求直接回 busy |
release() 归还并发名额与流式资源(识别器状态、累计文本缓冲),这是「不会因为一次异常录音把唯一的会话名额占死」的唯一保证。
stop 的处理值得单独说明:发起定稿的那一刻就 release(),final 帧等 worker 回来再补发。也就是说定稿期间用户可以立刻开始下一次录音,不必等几百毫秒到数十秒的推理结束。代价是客户端要能接受「会话已结束但结果还没到」,这由下文的兜底定时器覆盖。
定稿:worker 线程池
sherpa-onnx 的 decode 是同步原生调用,耗时从几百毫秒到数十秒。若放在主线程的 WebSocket 回调里,整个 Agent 的事件循环会停摆:/health 探活、cancel 帧、聊天流式响应、工具执行全部排队,用户看到的现象是「点了停止代理像死了一样」。
因此 offline 推理搬到 worker_threads(offline-pool.js + offline-worker.js),主线程只等消息:
| 设计点 | 原因 |
|---|---|
| 单 worker + 串行队列 | 本机单用户形态下并发定稿只会抢同一份 CPU 与内存;串行也保证 worker 内那个几百 MB 的 ONNX 会话不会被两次 decode 交错使用 |
| 首个任务才 spawn | 未启用 ASR 的用户零成本 |
空闲 idleMs 后 dispose | terminate 把几百 MB 原生内存还给系统 |
| 崩溃 / 超时 → 丢弃 worker | reject 在途任务,下个任务重新 spawn;默认单任务超时 180 秒 |
| spawn 失败 → 退回主线程 | 宁可卡一下,也不能让语音输入整个不可用 |
一个容易误解的点:worker_threads 与主线程共享同一进程地址空间,process.memoryUsage().rss 覆盖 worker 内的原生分配。所以把推理搬进 worker 不会让 RSS 保护失准,反而让保护继续有效。
降级与保护
| 场景 | 行为 |
|---|---|
无 offline 模型 | ready.finalize=false,只做流式,stop 时直接用草稿文本 |
| 定稿失败 | final 带 fallback=true,用草稿兜底,不向用户抛错 |
| RSS 超限 | 超过 RSS_LIMIT_BYTES(默认 3.5GB)跳过 offline 推理,回 rss_pressure 并退化 |
| worker 超时 | 同上,丢弃 worker 后回退 |
降级的判断标准是「用户能否拿到文字」,而不是「是否拿到最好的文字」。任何一次异常都不应该让整段录音白说。
客户端配合
src/side_panel/voice-input.js 承担上行采集与状态机:
- 采集优先用
AudioWorklet(voice-capture-worklet.js),addModule失败时回退ScriptProcessor。两条路径都要工作,因为不同安装形态下 worklet 文件的相对位置不同。 - 优先向
AudioContext请求 16kHz;拿不到就在回调里做线性插值重采样,保证上送格式恒为 16kHz。 - 点开始前再跑一次门控复查。
refreshGating()是异步的,这期间若另一路(快捷键 / 按钮)已经进入starting或recording,不复查就会出现两路并发采集——前一组AudioContext与MediaStream被后者覆盖且永不释放(麦克风指示灯常亮),两路帧还会同时上送。 - stop 之后起 60 秒兜底定时器:代理端迟迟不回
final(网络挂住、模型加载慢)时用最后一条草稿收尾,不让 UI 卡在转写态。 - 太短的录音不发
stop,直接回滚草稿并提示,避免为一个语气词付一次完整推理。
结论
这条链路的可靠性来自三处明确的兜底:并发名额靠 release() 收敛、推理线程靠队列与超时收敛、结果靠 fallback 收敛。反过来说,任何一处缺失都会表现为「偶发卡死」或「偶发丢字」,且难以复现。新增相关功能时,这三条边界不应被绕过。