外观
上游适配器
代理的下游契约是固定的(见 WebSocket 协议),上游采用 Profile 模式进行配置与管理。 代理专为自身既无语音起始事件、也无服务端断句的三方语音流式识别引擎服务,将本地 Silero VAD 切出的有效语音流翻译成厂商的请求序列。 当前支持的 Profile Provider 为 iflytek-iat(科大讯飞私有云实时识别)。
| Provider | 传输 | 端点配置项 | 句子边界来源 | 上游采样率 |
|---|---|---|---|---|
iflytek-iat | HTTP JSON-RPC 2.0(keep-alive) | profiles.<name>.url | 本地 VAD 的语音段 | 8kHz(代理降采样) |
为什么会有厂商私有协议这一档
代理最初的边界是「不实现任何厂商私有协议」——要接裸厂商 API,由既有 TS 网关 (speech-gateway/*)负责翻译,代理串在网关之前。科大讯飞私有云实时识别(IAT)是刻意开的 例外,判据只有一条:它连服务端断句都没有。厂商要求调用方自己用 audioStatus = 4 声明 一句话结束,而这正是本地 Silero VAD 唯一能提供、别处拿不到的东西——放在网关里做,网关还得 再造一套 VAD;放在代理里做,语音段边界本来就在手上。
反过来说,凡是厂商自己会断句、只是不下发语音起始事件的(阿里云、Azure),仍然走对应网关, 不要往代理里加适配器。自建的具备 VAD 能力的识别服务(如 sherpa-asr-server / funasr-asr-server)更无需前置代理。
iflytek-iat
传输与请求外形
不是 WebSocket,是同一条 keep-alive HTTP 连接上的一串 POST:
POST <profile.url>(形如http://host:port/iat/),Content-Type与Accept均为application/json-rpc,Connection: keep-alive。- JSON-RPC 2.0 请求体,
method恒为"deal_request",命令名放在params.cmd。 - 响应体是 base64 编码的 JSON;解不出 base64 时回退当明文 JSON 解析(两种形态都见过)。
- 只支持
http://。适配器不实现 TLS,需要 https 时在前面放一层终结代理 (nginx / stunnel),启动期校验会直接拦下https://。
固定参数:params.appid(租户凭据)、svc(实时识别取 iat)、type(1 普通话 / 2 维语 / 3 英语 / 4 粤语)、busid、authNeed,以及 aue: "raw" 与 auf: "audio/L16;rate=<profile.sampleRate>"。
一个语音段 = 一次厂商会话
这是整个适配器的骨架:本地 VAD 每切出一个语音段,就完整走一遍 ssb → auw… → grs… → sse, 于是一次厂商会话恰好产出一句话的 final。
| 命令 | 触发时机 | 关键参数 | 作用 |
|---|---|---|---|
ssb | 语音段开始(VAD 上升沿后的首个音频块) | 无 syncid | 开会话,从 result.sid 取本段的 sid |
auw | 段内每攒够 profile.chunkBytes | audioStatus = 1(首块)/ 2(中间块),data = base64 音频 | 送音频;响应里的 rec_result 就是增量 partial |
auw | 段末(收到会话层的零长帧) | audioStatus = 4 | 声明这句话说完了——厂商没有服务端断句,全靠这一帧 |
grs | 段末,audioStatus = 4 之后 | 轮询 | 取尾部结果,resultStatus == 5 即收敛 |
sse | 本段收尾(含客户端中断) | 无 syncid | 释放厂商会话额度 |
csid 是 32 位小写十六进制,每段一个、本段内所有命令共用;sid 由 ssb 返回后在 auw / grs / sse 上回显。
计数器语义
参考实现的计数器规则不对称,抄错会被厂商判为乱序:
| 命令 | id | syncid | 发完是否自增 |
|---|---|---|---|
ssb | 固定 1 | 不带 | 否(计数器从它之后才开始走) |
auw | 计数器 + 1 | 计数器(字符串) | 是 |
grs | 计数器 + 1 | 计数器(字符串) | 是 |
sse | 计数器 + 1 | 不带 | — |
对着真实私有云跑一段 10.06s、含 3 句人声的素材,日志里的命令序列是:
text
ssb auw ×14 grs ×2 sse # 第 1 段
ssb auw ×14 grs ×2 sse # 第 2 段
ssb auw ×14 grs ×2 sse # 第 3 段单段内的 id / syncid:ssb id=1 syncid=- → auw id=2 syncid=1 … auw id=15 syncid=14 → grs id=16 syncid=15 → grs id=17 syncid=16 → sse id=18 syncid=-。
采样率与攒批
平台全链路是 16kHz 单声道 PCM16,讯飞默认 8kHz,所以适配器内置一次降采样:Hamming 窗 sinc 低通 + 2:1 抽取,滤波状态跨帧保留。输出不是输入的整半——抗混叠 FIR 有预热,首帧会少十几个 采样,写断言时别把「一半」硬编码进去。profile.sampleRate 填 16000 则直通不转换。
攒批两个旋钮:chunkBytes 是触发一次 auw 的下限,maxChunkBytes 是单次 auw 的上限。 音频来得比请求快时,适配器会把队列里已到的块并成一个 auw(不超过上限)发出去,避免请求数 随推流节奏膨胀。两者都按降采样之后的字节数计。
文本与序号
rec_result是增量片段,适配器把本段收到的片段依次拼接,每次下发的text是累计全文 (1→12→123),与平台契约要求的累进 partial 一致。utteranceIndex由适配器自持:从 1 起,每次厂商会话结束后 +1,所有帧显式携带。 空段(噪声误触发、识别无结果)也照样自增——否则适配器的序号会与会话层的段计数永久错开 一位,下游按serverSessionId + utteranceIndex归并时,VAD 帧与文本帧会落进不同的桶。- 空文本 final 被抑制(只记一条带本段字节数的
warn)。下游apps/callflow-esl/src/runtime/controllers/recognition-controller.ts会把isFinal && !text判成failureReason: "missing-transcript"直接结束这次hear, 噪声段的空 final 会白白吃掉一轮对话。这是唯一一处「代理主动丢掉上游结果」。 - 段末无音频(只有零长帧)时不开厂商会话,只发
Idle,不产生任何请求。
错误判定按命令分档
参考实现在四条命令上的判定口径并不统一,照抄「ret != 0 一律致命」会把正常通话打断 (ret 可能是数字也可能是字符串,两种都要接):
| 命令 | 顶层 error | result.ret != 0 | 处置 |
|---|---|---|---|
ssb | 致命 | 致命 | 本段根本没开起来,向下游发 error + disconnect |
auw | 致命 | 正常 | 中间块「还没出结果」是常态,不判错 |
grs | 提示 | 提示 | 都只表示「结果已取完」,记 warn 后按累计文本收尾 |
sse | 提示 | 提示 | 永不致命,只记 warn(会话额度可能漏在厂商侧) |
grs 轮询以 resultStatus == 5 收敛,受 grsMaxPolls(默认 100)× grsPollIntervalMs (默认 20ms)约束,超限同样按累计文本收尾并记 warn。
客户端中断(close / 会话放掉发送端)时刻意跳过 grs:最坏 grsMaxPolls × grsPollIntervalMs 要 2s,而拆链只有 500ms 宽限,轮到一半照样会被砍断, 不如把这点时间留给 sse,至少别把厂商的会话额度漏在那里。这条路径仍会发 audioStatus = 4 与 sse,并按已累计的文本收尾。
凭据与环境变量
激活 Profile 的 url 与 appid 在仓库内的 config.toml 里一律留空,实际值由 IFLYTEK_IAT_URL / IFLYTEK_IAT_APPID 环境变量注入(环境优先),与两个云网关的凭据姿态一致。 logging.debugTextFrames 打开后每条命令记一行摘要,只含 cmd / id / syncid / ret / body_bytes——params.data 是几 KB 的 base64 音频、params.appid 是租户凭据,两者都不落盘。
排障要点
先看 /health 的 profile、provider 与 upstream 字段,确认跑的确实是你以为的配置。
| 症状 | 大概率原因 |
|---|---|
启动即退出,提示 defaultProfile 不存在 | 检查 upstream.defaultProfile 是否与 upstream.profiles 中的键匹配 |
启动即退出,提示 url 必须是 http:// | 填了 https;前面加一层 TLS 终结,代理只连明文端口 |
启动即退出,提示 url 或 appid 缺失 | 忘记配置或未通过环境变量注入 IFLYTEK_IAT_URL / IFLYTEK_IAT_APPID |
建链就失败(通话一开始就 error + disconnect) | 建链时那次 TCP 连通性探测没过:地址、端口或防火墙。探测受 connectTimeoutMs 约束 |
| 有 VAD 帧、没有任何 partial | 打开 debugTextFrames 看 ssb 的 ret:非 0 说明 appid / svc / type 组合被厂商拒了 |
| partial 一直在跳、不像累进 | rec_result 是增量片段,若看到的是逐片替换而非累计全文,说明拼接被绕过了 |
| 每句话都缺尾字 | grs 轮询过早收敛:调大 grsMaxPolls 或 grsPollIntervalMs 再看日志里的 resultStatus |
| 段落切得太碎 / 太长 | 与上游无关,是 vad.minSilenceDuration / maxSpeechDuration,见集成与排障 |
| 厂商侧会话数只增不减 | sse 没发出去(看日志 warn)。拆链路径也必须发 sse,这是释放额度的唯一命令 |
| 日志里出现大段 base64 | 不该发生。debugTextFrames 只摘四个字段,出现即是回归 |
一次会话结束时固定打一条统计(audio_bytes / forwarded_bytes / utterance_index 等), 用 forwarded_bytes / audio_bytes 直接看闸门拦下了多少上行流量——这一段省下的就是云端按时长 计费的静音部分。
最小验证
powershell
# 凭据用环境变量注入,config.toml 保持留空
$env:IFLYTEK_IAT_URL = "http://<host>:<port>/iat/"
$env:IFLYTEK_IAT_APPID = "<appid>"
cd onnx-platform\stream-vad-proxy\dist\win_x64
.\stream-vad-proxy.exe
bun run onnx-platform\sherpa-asr-server\request-ws.js --file <audio.wav> --port 10093
bun run onnx-platform\sherpa-asr-server\request-ws.js --silence --port 10093四条验收线:每段日志里恰好一次 ssb / 若干 auw / grs 收敛 / 一次 sse;partial 是累计 全文;同一句的 partial 与 final 带同一个 utteranceIndex,下一句 +1;--silence 不产生 任何厂商请求,也不产生 final。
扩展新的 Profile Provider
若未来需引入其它无 VAD、无服务端断句的专有上游:
- 在
src/upstream/<vendor>.rs实现连接与协议交互,内部驱动UpMessage队列并向UpstreamEvent通道发帧。 - 结果要等段末才收敛的适配器,段末必须发
UpstreamEvent::Idle—— 会话层的排空逻辑靠它结束等待。 - 在
src/config.rs的ProfileConfig枚举中增加新的 Provider 分支与参数结构体,并实现相应的validate()逻辑。 - 在
src/upstream.rs的connect()中增加该 Provider 的分派分支。 - 下游契约一个字节都不要改:八个必填字段照旧、
text/isFinal只来自上游、可选字段缺失时省略而不是给null。