Skip to content

流式会话与定稿引擎 ​

本文引用的源码文件

本文引用的文件

引言 ​

语音链路分两段:流式(草稿)与定稿。流式让用户边说边看到文字,定稿在松手后用整段音频重转一遍拿到更高精度结果。两段用的不是同一个模型:online 包负责低延迟草稿,offline 包负责终稿。

本文说明这条链路上最容易被忽视的三件事:WebSocket 的鉴权与心跳、会话资源的收敛路径、以及为什么 offline 推理必须离开主线程。

目录 ​

  1. 通道建立
  2. 帧协议
  3. 会话生命周期与资源回收
  4. 定稿:worker 线程池
  5. 降级与保护
  6. 客户端配合
  7. 结论

通道建立 ​

升级请求由 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 后 disposeterminate 把几百 MB 原生内存还给系统
崩溃 / 超时 → 丢弃 workerreject 在途任务,下个任务重新 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 收敛。反过来说,任何一处缺失都会表现为「偶发卡死」或「偶发丢字」,且难以复现。新增相关功能时,这三条边界不应被绕过。