整理时间:2026-09-23 | 依据:实测(
harness/tools/probe-nls-realtime.mjs打真实网关逐帧看过载荷)+ 阿里云官方文档要点 性质:现行实现的契约记录。替换的是旧的厂商 SDK(asr-realtime.md记录的那套), 切换完成后那份文档只作为"旧实现"的历史参考。
旧的实时语音识别走厂商 metamaker 的中转(/common/asr_hub?...&engine=aliyun_dashscope,
asr-realtime.md 有完整记录)。2026-09-23 用户要求换成阿里云 NLS 直连。
前端这一侧的关键差别:前端不再持有任何云凭据。NLS 的 token 要用 AccessKeySecret 签名换取,而 secret 不能进前端产物 —— 所以由服务端换好 token 再下发:
浏览器 ──GET /nls-token/──▶ 服务端(dev:vite 中间件,见 harness/tools/nls-token.mjs)
│ 用 AK/SK 签 CreateToken
▼
阿里云 nls-meta ← 返回 token(24h)
浏览器 ◀── { token, expireAt, appkey } ──┘
浏览器 ──wss://nls-gateway-cn-shanghai.aliyuncs.com/ws/v1?token=…──▶ NLS 网关
| 件 | 在哪 |
|---|---|
| 前端取 token(带缓存) | src/network/api/nls-token.ts |
| 协议层(纯函数,可被 harness 测) | src/three-libs/asr/nls-protocol.ts |
| 采集与连接(对外契约与旧 SDK 一致) | src/three-libs/asr/nls.ts |
| 服务端换 token(dev) | vite.config.ts 的 /nls-token/ + harness/tools/nls-token.mjs |
| 验证脚本 | harness/tools/verify-nls-realtime.mjs(30 项,含真实识别) |
| 探针(读原始帧用) | harness/tools/probe-nls-realtime.mjs |
wss://nls-gateway-cn-shanghai.aliyuncs.com/ws/v1?token=<token>
客户端发文本帧(JSON 指令)与二进制帧(音频):
| 指令 | 关键字段 |
|---|---|
StartTranscription |
header:appkey / message_id / task_id / namespace=SpeechTranscriber / task=start / name;payload:format=pcm、sample_rate=16000、enable_intermediate_result=true、enable_punctuation_prediction=true、enable_voice_detection=true、max_sentence_silence(200~2000,默认用 800) |
StopTranscription |
同 header(task=stop、同一个 task_id) |
🚨 两条实测踩出来的硬约束(都在 nls-protocol.ts 里注释了):
appkey —— 只有 start 带时,stop 会回
TaskFailed 40000002 Gateway:MESSAGE_INVALID:Missing message appkey!task_id 不能是空串 —— 否则回
TaskFailed 40000002 Gateway:MESSAGE_INVALID:Invalid task id ''!
(所以 buildStartTranscription 不传就自己生成,调用方把返回帧里的 task_id 复用到 stop)| 网关事件 | 含义 | 前端产出 |
|---|---|---|
TranscriptionStarted |
开始识别 | openSocket() 的 await 在这里 resolve |
SentenceBegin |
一句开始(VAD) | 不产出(状态不变) |
TranscriptionResultChanged |
中间结果 | transform 事件 type=mid,transformText = 已定稿全文 + 本句中间结果 |
SentenceEnd |
一句定稿 | transform 事件 type=fin,把整句追加进全文 |
TranscriptionCompleted |
收尾完成 | transform() 的 await 在这里 resolve |
TaskFailed |
出错 | emit error + 拒绝 openSocket()/transform() |
⚠️ 拼接规则(关键):中间结果会被后续结果覆盖(实测:"青浦区高新" → "青浦区高新技术企" →
… 是同一句的逐步增长),所以 SentenceEnd 的整句才是能拼进全文的部分,
中间结果只用来实时显示。这条已写成断言(verify-nls-realtime.mjs 的【1】)。
getUserMedia → MediaStreamAudioSourceNode → ScriptProcessorNode
(与旧实现同一套;audioContext / source 仍然暴露给外面画波形,PC 那边在用)audioContext.sampleRate 实际值算(encodePcm16kMono(input, fromRate))——
旧实现写死 44100,设备是 48000 时会送出"慢约 8.8% 的 16k"(见旧文档 §9.1),新实现不再沿用StopTranscriptionconst asr = new NlsAsr(appKey?, CONSTANTS?); // appKey 现在是兜底:真正用端点下发的 appkey
await asr.openMicroPhone(); // 失败抛 Error(NotAllowedError → "权限被拒绝")
asr.addEventListener('transform', (e) => { inputText.value = e.transformText; });
asr.startRecord(); // 必须在 openSocket() 之前
await asr.openSocket(); // 取 token → 连网关 → 等 TranscriptionStarted
const { text } = await asr.transform(); // 停采集 → 发完余量 → StopTranscription → 定稿全文
asr.close();
asr.clear(); // 之后实例作废(壳组件置 null)
事件负载与旧实现同形:{ type: 'mid' | 'fin', current, transformText, command }
(旧实现还有 breath,NLS 版没有对应概念 —— 壳组件本来就不区分 type,只取 transformText)。
npx esbuild harness/tools/_entry-nls.ts --bundle --format=esm \
--outfile=harness/tools/_nls.mjs --alias:@=./src --define:import.meta.env='{}'
node harness/tools/verify-nls-realtime.mjs
Microsoft Huihui Desktop),
按 100ms 推流给真实网关,断言"识别期间有中间结果 + 定稿文本包含关键内容"。
产物音频落在 harness/tools/fixtures/_*(gitignore)。
没有凭据 / 不是 Windows 时这一段明确跳过,不会假装通过。排查协议问题用探针(它把上下行每一帧原样打出来):
node harness/tools/probe-nls-realtime.mjs # 只握手
node harness/tools/probe-nls-realtime.mjs --say="青浦区高新技术企业奖励是多少钱"
BusinessAssistantPC.vue / BusinessAssistantMobile.vue
目前仍 import ASR from '@/three-libs/asr/index'(旧实现)。切换只需改 import
(契约一致),切完请在浏览器里点一次麦克风实测/nls-token/(技术债 #10),否则线上拿不到 tokenasr/index.ts)保留:切回它只改 import,且它承载着"厂商中转"那条退路