跳到正文

WebSocket 协议 ​

代理对下游(mod_audio_fork)提供 WebSocket 服务,并由激活的 Profile 适配器调用厂商上游。 下游调用面与 onnx-platform/sherpa-asr-server 保持必要兼容:相同的握手子协议、首帧 metadata、 16kHz mono PCM16、零长 flush 及事件外形;上游传输、VAD、断句和供应商能力不属于该兼容承诺。

健康检查 ​

text
GET /health

成功响应(HTTP 200)

json
{
  "ok": true,
  "service": "stream-vad-proxy",
  "activeSessions": 1,
  "maxSessions": 256,
  "wsPath": "/audio",
  "upstream": "ws://127.0.0.1:10096/audio",
  "upstreamConnected": true
}
  • upstreamConnected 表示当前是否有会话持有上游连接。代理按连接建立上游链路, 空闲时为 false,这不是故障;它也不能用来判断上游可达。
  • 任何其它方法 / 路径,既不是 healthPath 也不是升级到 wsPath,一律返回 404 JSON ({"error":"not found"})。

WebSocket 升级 ​

text
GET /audio HTTP/1.1
Host: <host>:<port>
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Version: 13
Sec-WebSocket-Key: <base64-16>
Sec-WebSocket-Protocol: audio.drachtio.org

握手要求:

  • Sec-WebSocket-Version: 13 且 Connection: upgrade / Upgrade: websocket,否则 400
  • server.wsSubprotocol 非空时,请求必须在 Sec-WebSocket-Protocol 里带上对应 token (逗号分隔、大小写不敏感),否则 400 {"error":"missing required websocket subprotocol"}
  • 超过 concurrency.maxSessions 返回 503 {"error":"max sessions exceeded"}

升级成功后代理立刻做两件事:在阻塞线程池里建一个会话独占的 VAD(约 83~97ms), 并初始化激活的 Profile 上游连接。任一失败都会向下游发 error + disconnect 再按 1011 关闭, 不静默断开——吞掉这类故障会表现成「通话没识别但没人报警」。

客户端消息(下游 → 代理) ​

帧处理
首个文本帧 (0x1)视作 metadata:整体留存,按 uuid → channel_uuid → call_uuid → sessionId → session_id 顺序取 channelUuid;当前 iflytek-iat 适配器不把该 JSON 作为厂商命令发送
后续文本帧 (0x1)作为上游适配层控制消息入队;当前 iflytek-iat 适配器忽略它
二进制 (0x2) 非空16kHz 单声道 PCM16 LE:录音 → 喂 VAD → 下发 VAD 边沿帧 → 按闸门转发上游
二进制 (0x2) 零长flush:结算本地 VAD(flushReason: "zero-length-binary")→ 向上游适配器发送段末 flush → 就地排空尾部结果
close (0x8)flush(flushReason: "client-close")→ 给上游补零长帧与 close → 回 close 1000 bye
ping (0x9) / pong (0xA)忽略(axum 自动回 pong)
奇数字节的 PCM 帧协议错误:error(invalid pcm16 frame)+ close 1002
超过 socket.maxFrameBytes协议错误:error(audio frame exceeds maxFrameBytes)+ close 1002
TCP 断开 / 读错误flush(flushReason: "socket-read-end")后拆链,不再向下游发帧

关闭码:1000 正常结束、1002 协议错误、1011 内部错误(含上游故障)。

服务端事件(代理 → 下游) ​

一律是文本帧,三种 type:

json
{"type":"transcription","data":{"text":"","isFinal":false,"utteranceIndex":1,
 "speechStarted":true,"speechActive":true,"speechSegmentDetected":true,
 "speechSegmentCompleted":false,"serverSessionId":"vad-1","channelUuid":"<fs-uuid>"}}
{"type":"error","data":{"message":"upstream connect failed: ..."}}
{"type":"disconnect","data":{"reason":"upstream connect failed: ..."}}

transcription.data 的字段:

字段类型必填说明
textstring是代理自造的帧恒为 "";识别文本由上游适配器产出
isFinalbool是代理自造的 VAD 帧恒为 false;识别结果的值由上游适配器决定
utteranceIndexuint是语句序号,从 1 起,每结算一段 +1
speechStartedbool是本次语音段已开始
speechActivebool是当前正在说话
speechSegmentDetectedbool是已检测到有效语音段
speechSegmentCompletedbool是本段已结束(静音达阈值)
serverSessionIdstring是代理会话标识,VAD 自造帧为 vad-<n>
channelUuidstring否从 metadata 解析到才带
metadataTextstring否客户端 metadata 原文,代理自造的帧都会回带
finalReasonstring否只透传上游给的值,代理不自造
flushReasonstring否由 flush 触发的段完成帧才带

八个必填字段任何分支下都必须存在:下游 getBoolean() 把缺失字段视作 false, 少一个就会静默丢掉打断信号。可选字段缺失时省略而不是给 null。

metadataText 每帧都回带是刻意的:apps/callflow-esl 的识别控制器按 metadata 里的 turnId 做匹配,不带就会被当成过期轮次丢弃。

三类下发时机 ​

  1. VAD 起始帧(本项目的核心增量):detected() 上升沿即发,不等上游文本。 text:""、isFinal:false、speechStarted / speechActive / speechSegmentDetected 全为 true。受 bargeIn.emitSpeechStartFrame 控制。
  2. 段完成帧:语音段结束时发,speechSegmentCompleted:true、speechActive:false, 随后 utteranceIndex +1。受 bargeIn.emitSegmentCompletedFrame 控制。 注意 speechSegmentDetected 在这一帧回落为 false——把它锁存成 true 会让 speech-start 策略下多出一次无意义的停播。
  3. 上游适配器结果:适配器把厂商结果转换成平台 transcription 外形后下发; text / isFinal 只来自厂商结果,VAD 四个布尔位按代理当前状态补齐。

打断口径由下游决定(apps/callflow-esl/src/speech/audio-fork.ts):

bargeInTrigger打断条件
"speech-start"speechStarted || speechActive || speechSegmentDetected || hasText
"partial"(默认)仅 hasText(出字打断)
"final"isFinal && hasText(整句完成打断)

所以空文本 VAD 帧在 speech-start 下毫秒级命中,在默认 partial 下 hasText=false 不触发——默认出字打断抗底噪,开嗓模式毫秒级打断。

上游适配器侧 ​

  • 会话建立时初始化当前 upstream.defaultProfile 对应的适配器,超时与连接参数由该 Profile 管理。
  • 有效音频按本地 VAD 闸门送入适配器;段末零长帧被翻译为厂商收尾命令。
  • 适配器在会话内不做黑盒重连:连不上或处理中断时,代理向下游发 error + disconnect 并按 1011 关闭。
  • 当前 iflytek-iat 使用 HTTP JSON-RPC,不是 WebSocket 透传;具体 ssb / auw / grs / sse 时序见上游适配器。

会话与适配器通过有界 mpsc 解耦;上游变慢时会产生背压,但本地 VAD 与下游事件处理仍保持独立。

客户端 flush 与上游排空 ​

客户端零长二进制帧的语义是 flush。代理先结算本地 VAD,再通知上游适配器完成当前语音段, 并在同一连接内排空适配器尚未交付的尾部 final。

为什么必须在这一步做:一旦读到下游的 close 帧,tungstenite / axum 就进入 ClosedByPeer 并自动回 close,之后任何 send 都会失败(Sending after closing is not allowed)。 request-ws.js 与 mod_audio_fork 都是零长帧紧接着 close,所以flush 帧是尾部结果 唯一的投递窗口。同理,排空过程中绝不能去读下游——读到 close 就等于自断投递通道, 实测表现为「排空 0ms 结束、上游文本全丢」。

两级计时器:

参数作用
drainTimeoutMs(默认 8000)单次排空的硬上限,同时是等第一帧的宽限
drainIdleMs(默认 1500)收到首帧之后的帧间空闲上限,静默达阈值即结束

跳过条件:pending_upstream_bytes == 0,即适配器已确认没有待交付结果;实时通话通常几乎不产生 额外等待,极速推流时才会真正走进排空循环。drainTimeoutMs 设为 0 则关闭这段等待。

连接生命周期 ​

text
握手 → 建 VAD(独占)+ 连上游
  → 客户端 metadata → 转发上游
  → 音频帧循环:喂 VAD → [上升沿] 下发起始帧 + 冲 pre-roll → 实时转发
                        → [段完成] 下发完成帧 + 关闸 + 清 pre-roll
  → 上游 partial / final 透传(覆盖 VAD 位)
  → 零长 flush → 本地结算 + 转发上游 + 排空上游
  → close → 回 close 1000 → 拆上游 → 打一条会话统计日志

会话结束时的统计日志包含 audio_bytes / forwarded_bytes / start_frames / completed_frames / upstream_frames / utterance_index,用它对账闸门拦下了多少音频、 下发了几次打断信号。

文档与代码在同一仓库维护,现有 Markdown 是唯一内容源。