整理时间: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 直连。
前端这一侧的关键差别:前端不持有任何云凭据(签名要 AccessKeySecret,而 bundle 是公开的)。 token 由后端网关签发(2026-09-24 起,用户指定):
浏览器 ──GET /api/proxy/verify/getALYToken──▶ 同源代理
(dev:vite 代理 → http://121.43.55.7:10011;生产:nginx 转发同一路径)
▼
后端网关 ──(AK/SK 签名)──▶ 阿里云 nls-meta
浏览器 ◀──── { code:200, message:"成功", content:"<32 位 token>" } ────┘
浏览器 ──wss://nls-gateway-cn-shanghai.aliyuncs.com/ws/v1?token=…──▶ NLS 网关
⚠️ 端点只有 HTTP、页面是 HTTPS → 必须走同源路径,直连会被混合内容拦掉。 它与取本机 IP 是同一台机器(121.43.55.7:10011),2026-09-29 起共用
/api/这一条代理 (代理会剥掉/api前缀:网关真实路径是/proxy/verify/…)。 生产要在 nginx 里做同样的事:把/api/转发到http://121.43.55.7:10011/(去前缀)。 网关不返回过期时间,前端按官方文档的 24 小时估(TOKEN_LIFETIME_MS)。
| 件 | 在哪 |
|---|---|
| 前端取 token(带缓存) | src/network/api/nls-token.ts |
| 协议层(纯函数,可被 harness 测) | src/asr/nls-protocol.ts |
| 采集与连接(对外契约与旧 SDK 一致) | src/asr/nls.ts |
| 取 token 的三种来源(工具侧统一入口) | harness/tools/nls-token.mjs:显式 NLS_TOKEN → 网关 → AK/SK 自签(退路) |
| 验证脚本 | harness/tools/verify-nls-realtime.mjs(42 项,含真实识别) |
| 探针(读原始帧用) | harness/tools/probe-nls-realtime.mjs |
配置项(两个,都不含密钥):
| 变量 | 值 | 说明 |
|---|---|---|
VITE_NLS_TOKEN_API |
/api/proxy/verify/getALYToken |
同源路径(与取本机 IP 共用 /api 代理,2026-09-29 起);两份 env 都写了 |
VITE_NLS_APPKEY |
NLS 的 appkey | 不是密钥(换 token 不需要它、网关也不校验它),而且每次识别请求本就会把它明文发出去。2026-09-28 起直接内置在代码里(src/utils/runtime-config.ts 的 NLS_APPKEY_BUILTIN)—— 不再需要部署侧注入,也不要求 .env.*.local 里有它;env / globalThis 仍可覆盖 |
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),新实现不再沿用StopTranscription先量化,再改(probe-nls-realtime.mjs 会打印「感知延迟 = 麦克风已说多少 − 网关处理到第几毫秒」):
| 现象 | 实测 |
|---|---|
| 网关中间结果节奏 | 恒 ~480ms 一次(max_sentence_silence 调 300/800 都不变;开 enable_semantic_sentence_detection 也不变)→ 这是引擎固有的,前端改不了 |
| 网关首个中间结果 | 音频开始后约 480~720ms |
定稿 SentenceEnd |
说话停下后 ≈ max_sentence_silence + 处理时间(阈值 300 比 800 早到约 300ms) |
| 客户端落后(按 1× 实时推时) | 握手期间攒下的积压永远排不掉 → 感知延迟永久 ~1.4s |
根因:连上网关之前(取 token + WS 握手,通常 0.2~1s)麦克风已经开始采集,这段音频先攒着; 如果之后只按 1× 实时 排空,积压就一直是那么多 ⇒ 整体恒定慢半拍。 (切过来之前那套厂商 SDK 是按 2× 排空的,所以它能追上 —— 这是我这次切换引入的一处退化。)
改法:排空策略改成「平时按实时、积压时最多 4× 追」
(NLS_FLUSH_TICK_MS=50、NLS_MAX_FRAMES_PER_TICK=2,见 nls-protocol.ts)。
A/B 实测(模拟 1s 握手积压、同一段语音):
| 排空策略 | 感知延迟(稳定期) | 定稿时 |
|---|---|---|
| 1× 实时 | 1377~1434ms(且不收敛) | 2087ms |
| 4× 追赶(现在) | 145~183ms | 683ms |
另外两处同方向的调整:停止时的余量按 ~5× 发(用户此刻正等最后一段文字,不该再按实时节奏等)、
断句阈值默认 500ms(网关默认 800,取中:说得快的人不被切断,说一句停一下的人更快看到定稿)。
这三条都有断言守着(verify-nls-realtime.mjs 的【2】)。
剩下的 ~150ms 是客户端+链路,~480ms 的更新节奏是引擎的 —— 想再快只能换引擎/参数, 不要再往客户端"提前发送"上想办法(文档要求按实时速率发;4× 是追赶积压的上限,不是常态)。
现象:点了麦克风、进了录音态、音频一帧帧都在发([nls] 已推音频 …),
但网关一条结果都不回、也不报错(没有 TaskFailed)。采集电平恒为 峰值=0.0000。
根因:Windows/Chrome 选的输入设备是虚拟麦克风。实测踩到的是
Steam Streaming Microphone(装 Steam 时装的虚拟输入设备,永远输出静音)。
getUserMedia 成功、音轨 muted=false state=live、回调照常触发 —— 但采样全是 0。
怎么一眼认出来:看采集开始那两行日志
[nls] 音轨:label="麦克风 (Steam Streaming Microphone)" muted=false enabled=true state=live
[nls] 采集电平:峰值=0.0000 RMS=0.0000 ← 连续 3 秒这样就会自动告警并点名设备
怎么修:Windows「设置 → 系统 → 声音 → 输入」换成真实麦克风(并在「更多声音设置 → 录制」 里禁用虚拟麦),或改浏览器的站点麦克风设置。
⚠️ 这不是本实现引入的问题:旧厂商 SDK 用的是一模一样的采集路径 (
getUserMedia({audio:true,video:false})+createMediaStreamSource→createScriptProcessor(0,1,1)→connect(destination)),选哪台设备由 Chrome/Windows 决定 —— 旧实现遇到同一台虚拟麦,表现完全相同。排查经过见progress.md续 41。
const 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 '@/asr/nls'(2026-09-24 前是 @/three-libs/asr/nls),并把构造函数里的硬编码 appKey 去掉
(appkey 由端点下发)。契约一致,所以除了 import 与那一处构造参数,其余一行没改
—— 有源码级断言守着(verify-nls-realtime.mjs 的【4】)audioContext/source,契约没变)/api/ 转发到 http://121.43.55.7:10011/(去前缀)。
token 已由后端网关签发(/proxy/verify/getALYToken,2026-09-24 起),前端不持有 AK/SK;
appkey 也已内置进代码(2026-09-28)⇒ 不再需要部署侧注入three-libs/asr/index.ts、VITE_ASR、VITE_XF_ASR
与 vite 的 /asr/ 代理都没了 —— src/asr/ 下现在只有 NLS 实现(nls.ts + nls-protocol.ts),
两个壳组件 import 的是 @/asr/nls