# 实时语音识别(阿里云 NLS 版)—— 实现与协议 > **整理时间**:2026-09-23 | **依据**:**实测**(`harness/tools/probe-nls-realtime.mjs` > 打真实网关逐帧看过载荷)+ 阿里云官方文档要点 > **性质**:现行实现的契约记录。**替换的是旧的厂商 SDK**(`asr-realtime.md` 记录的那套), > 切换完成后那份文档只作为"旧实现"的历史参考。 ## 1. 为什么换、换成了什么 旧的实时语音识别走厂商 metamaker 的中转(`/common/asr_hub?...&engine=aliyun_dashscope`, `asr-realtime.md` 有完整记录)。2026-09-23 用户要求**换成阿里云 NLS 直连**。 前端这一侧的关键差别:**前端不持有任何云凭据**(签名要 AccessKeySecret,而 bundle 是公开的)。 token 由**后端网关**签发(2026-09-24 起,用户指定): ``` 浏览器 ──GET /aly-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-api/` 同一个坑、同一台机器)。生产要在 nginx 里把 `/aly-api/` > 转发到 `http://121.43.55.7:10011/`(去掉前缀)。 > 网关**不返回过期时间**,前端按官方文档的 24 小时估(`TOKEN_LIFETIME_MS`)。 | 件 | 在哪 | |---|---| | 前端取 token(带缓存) | [`src/network/api/nls-token.ts`](../../../src/network/api/nls-token.ts) | | 协议层(**纯函数**,可被 harness 测) | [`src/asr/nls-protocol.ts`](../../../src/asr/nls-protocol.ts) | | 采集与连接(**对外契约与旧 SDK 一致**) | [`src/asr/nls.ts`](../../../src/asr/nls.ts) | | 取 token 的三种来源(工具侧统一入口) | [`harness/tools/nls-token.mjs`](../../tools/nls-token.mjs):显式 `NLS_TOKEN` → **网关** → AK/SK 自签(退路) | | 验证脚本 | [`harness/tools/verify-nls-realtime.mjs`](../../tools/verify-nls-realtime.mjs)(42 项,含真实识别) | | 探针(读原始帧用) | [`harness/tools/probe-nls-realtime.mjs`](../../tools/probe-nls-realtime.mjs) | **配置项**(两个,都不含密钥): | 变量 | 值 | 说明 | |---|---|---| | `VITE_NLS_TOKEN_API` | `/aly-api/proxy/verify/getALYToken` | 同源路径;已在 `.env.development`,**生产三份 env 也要写** | | `VITE_NLS_APPKEY` | NLS 的 appkey | **不是密钥**(换 token 不需要它、网关也不校验它),但按约定不写进仓库:开发期在 `.env.development.local`(gitignored),**生产必须由部署侧注入,否则识别会因空 appkey 失败** | ## 2. 连接与指令 ``` wss://nls-gateway-cn-shanghai.aliyuncs.com/ws/v1?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` 里注释了): 1. **每一条指令都要带 `appkey`** —— 只有 start 带时,stop 会回 `TaskFailed 40000002 Gateway:MESSAGE_INVALID:Missing message appkey!` 2. **`task_id` 不能是空串** —— 否则回 `TaskFailed 40000002 Gateway:MESSAGE_INVALID:Invalid task id ''!` (所以 `buildStartTranscription` 不传就自己生成,调用方把返回帧里的 task_id 复用到 stop) ## 3. 下行事件与"文本怎么拼" | 网关事件 | 含义 | 前端产出 | |---|---|---| | `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】)。 ## 4. 音频 - **16k / 16bit / 单声道 / 小端 PCM**,二进制帧推给网关 - 采集:`getUserMedia` → `MediaStreamAudioSourceNode` → `ScriptProcessorNode` (与旧实现同一套;`audioContext` / `source` 仍然暴露给外面画波形,PC 那边在用) - 重采样:**按 `audioContext.sampleRate` 实际值算**(`encodePcm16kMono(input, fromRate)`)—— 旧实现写死 44100,设备是 48000 时会送出"慢约 8.8% 的 16k"(见旧文档 §9.1),新实现不再沿用 - 节奏:每 **100ms** 推一帧(3200 字节 = 1600 采样)。缓存的音频先攒着,连上后按这个节奏推; 收尾时把余下的按同样节奏发完再发 `StopTranscription` ## 4.5 延迟:实测数字与「追赶」策略(用户 2026-09-23 报「出字延迟高」) **先量化,再改**(`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× 是追赶积压的上限,不是常态)。 ## 4.8 已知环境坑:虚拟麦克风会让识别"永远不出字"(2026-09-24 实测) **现象**:点了麦克风、进了录音态、音频一帧帧都在发(`[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。 ## 5. 对外契约(**与旧 SDK 一致,所以两个壳组件不用改**) ```ts 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`)。 ## 6. 怎么验证 ```bash 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 ``` - 【1】【2】纯协议与编码(不需要凭据) - 【3】**真实识别**:用 Windows 自带 TTS 造一段中文语音(`Microsoft Huihui Desktop`), 按 100ms 推流给真实网关,断言"识别期间有中间结果 + 定稿文本包含关键内容"。 产物音频落在 `harness/tools/fixtures/_*`(gitignore)。 没有凭据 / 不是 Windows 时这一段**明确跳过**,不会假装通过。 排查协议问题用探针(它把上下行每一帧原样打出来): ```bash node harness/tools/probe-nls-realtime.mjs # 只握手 node harness/tools/probe-nls-realtime.mjs --say="青浦区高新技术企业奖励是多少钱" ``` ## 7. 现状与未决 - ✅ 协议层、类、token 通路都已实现并**打真实网关验证通过**(39/39,含真实识别) - ✅ **两个壳组件已切过来**(2026-09-23):`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`,契约没变) - ⏳ **生产要由后端/网关提供 `/nls-token/`**(技术债 #10),否则线上拿不到 token - 📌 旧实现(`asr/index.ts`)**保留**:切回它只改 import,且它承载着"厂商中转"那条退路 (旧实现依赖的 `VITE_ASR` / `VITE_XF_ASR` 仍在四份 env 里)