外观
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,否则 400server.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 的字段:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
text | string | 是 | 代理自造的帧恒为 "";识别文本由上游适配器产出 |
isFinal | bool | 是 | 代理自造的 VAD 帧恒为 false;识别结果的值由上游适配器决定 |
utteranceIndex | uint | 是 | 语句序号,从 1 起,每结算一段 +1 |
speechStarted | bool | 是 | 本次语音段已开始 |
speechActive | bool | 是 | 当前正在说话 |
speechSegmentDetected | bool | 是 | 已检测到有效语音段 |
speechSegmentCompleted | bool | 是 | 本段已结束(静音达阈值) |
serverSessionId | string | 是 | 代理会话标识,VAD 自造帧为 vad-<n> |
channelUuid | string | 否 | 从 metadata 解析到才带 |
metadataText | string | 否 | 客户端 metadata 原文,代理自造的帧都会回带 |
finalReason | string | 否 | 只透传上游给的值,代理不自造 |
flushReason | string | 否 | 由 flush 触发的段完成帧才带 |
八个必填字段任何分支下都必须存在:下游 getBoolean() 把缺失字段视作 false, 少一个就会静默丢掉打断信号。可选字段缺失时省略而不是给 null。
metadataText 每帧都回带是刻意的:apps/callflow-esl 的识别控制器按 metadata 里的 turnId 做匹配,不带就会被当成过期轮次丢弃。
三类下发时机
- VAD 起始帧(本项目的核心增量):
detected()上升沿即发,不等上游文本。text:""、isFinal:false、speechStarted/speechActive/speechSegmentDetected全为true。受bargeIn.emitSpeechStartFrame控制。 - 段完成帧:语音段结束时发,
speechSegmentCompleted:true、speechActive:false, 随后utteranceIndex+1。受bargeIn.emitSegmentCompletedFrame控制。 注意speechSegmentDetected在这一帧回落为false——把它锁存成true会让speech-start策略下多出一次无意义的停播。 - 上游适配器结果:适配器把厂商结果转换成平台
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,用它对账闸门拦下了多少音频、 下发了几次打断信号。