跳到正文

上游适配器 ​

代理的下游契约是固定的(见 WebSocket 协议),上游采用 Profile 模式进行配置与管理。 代理专为自身既无语音起始事件、也无服务端断句的三方语音流式识别引擎服务,将本地 Silero VAD 切出的有效语音流翻译成厂商的请求序列。 当前支持的 Profile Provider 为 iflytek-iat(科大讯飞私有云实时识别)。

Provider传输端点配置项句子边界来源上游采样率
iflytek-iatHTTP 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.chunkBytesaudioStatus = 1(首块)/ 2(中间块),data = base64 音频送音频;响应里的 rec_result 就是增量 partial
auw段末(收到会话层的零长帧)audioStatus = 4声明这句话说完了——厂商没有服务端断句,全靠这一帧
grs段末,audioStatus = 4 之后轮询取尾部结果,resultStatus == 5 即收敛
sse本段收尾(含客户端中断)无 syncid释放厂商会话额度

csid 是 32 位小写十六进制,每段一个、本段内所有命令共用;sid 由 ssb 返回后在 auw / grs / sse 上回显。

计数器语义 ​

参考实现的计数器规则不对称,抄错会被厂商判为乱序:

命令idsyncid发完是否自增
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 可能是数字也可能是字符串,两种都要接):

命令顶层 errorresult.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、无服务端断句的专有上游:

  1. 在 src/upstream/<vendor>.rs 实现连接与协议交互,内部驱动 UpMessage 队列并向 UpstreamEvent 通道发帧。
  2. 结果要等段末才收敛的适配器,段末必须发 UpstreamEvent::Idle —— 会话层的排空逻辑靠它结束等待。
  3. 在 src/config.rs 的 ProfileConfig 枚举中增加新的 Provider 分支与参数结构体,并实现相应的 validate() 逻辑。
  4. 在 src/upstream.rs 的 connect() 中增加该 Provider 的分派分支。
  5. 下游契约一个字节都不要改:八个必填字段照旧、text / isFinal 只来自上游、可选字段缺失时省略而不是给 null。

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