Ver código fonte

feat(nls): 语音识别换阿里云 NLS —— 第 3 步:协议层 + 实现 + 真实网关验证

协议**不凭记忆**:官方文档 help.aliyun.com 被 WebFetch 策略挡掉,于是按本项目老规矩
直接打真实网关读原始帧(新增 harness/tools/probe-nls-realtime.mjs,逐帧打印上下行)。
实测踩到两条硬约束,都写进注释并变成断言:
1. **每条指令都要带 appkey** —— 只有 StartTranscription 带时,StopTranscription 回
   TaskFailed 40000002 Gateway:MESSAGE_INVALID:Missing message appkey!
2. **task_id 不能是空串** —— 回 Gateway:MESSAGE_INVALID:Invalid task id ''!
3. 事件流:TranscriptionStarted → TranscriptionResultChanged(中间结果,会被后续覆盖)
   → SentenceEnd(整句定稿,实测「青浦区高新技术企业奖励是多少钱?」)→ TranscriptionCompleted
   ⇒ 拼接全文只能用 SentenceEnd,中间结果只用于实时显示

实现(前端):
- src/three-libs/asr/nls-protocol.ts:纯函数协议层(帧构建 / 事件折叠 / 16k PCM 编码),
  抽出来的理由是这个部分"错了就静默不工作",纯函数才能被 harness 直接测
- src/three-libs/asr/nls.ts:NlsAsr 类,**对外契约与旧 SDK 完全一致**
  (openMicroPhone/startRecord/openSocket/transform/close/clear + transform/error 事件 +
  暴露 audioContext/source 供 PC 画波形)→ 壳组件切过来只改 import
- src/network/api/nls-token.ts:取 token(缓存到过期前 5 分钟 + 并发去重);
  runtime-config 加 getNlsTokenApiBaseUrl()(同源 /nls-token,可用 env 覆盖)
- 顺带修掉旧实现的隐患:重采样按实际 audioContext.sampleRate 算(旧实现写死 44100,
  48k 设备会送出"慢 8.8% 的 16k")

验证:新增 harness/tools/verify-nls-realtime.mjs **30/30**,含**真实端到端**:
Windows TTS 造语音 → 100ms 一帧推流给真实网关 → 识别结果与所说话一致
("青浦区高新技术企业奖励是多少钱?"),且识别期间确实收到中间结果(边说边出字)。
这条测试立刻抓到两个真 bug:① buildStartTranscription 不传 task_id 时发空串 → 网关 40000002
(已改成自动生成 32 位十六进制)② 我自己断言的事件索引写错。变异测试(改回空串)→ 6 项失败。
另跑:npm run build、vue-tsc(新文件无错)、validate-harness 全通过。

文档:新增 harness/docs/reference/asr-nls.md(现行实现),README 索引与 architecture.md
指向它;旧的 asr-realtime.md 顶部标注"这是换之前那套"。

⚠️ 两个壳组件**还没切过来**(仍用旧厂商实现)——切换只改 import,但需用户在浏览器实测确认。

Co-Authored-By: Claude Code <noreply@anthropic.com>
gongtianxiao 1 mês atrás
pai
commit
c5016ab820

+ 1 - 0
.gitignore

@@ -52,3 +52,4 @@ stats.html
 # (此前排除是本机工作流的临时安排;参考资料的原则是「计划、质量、技术债
 #  和代码一起版本化」——团队 clone 应当能看到状态、计划与契约文档)
 # 仍然排除的是带凭据的本机文件:.env.*.local(见上)与 .claude/settings.local.json
+harness/tools/fixtures/_*

+ 2 - 1
harness/docs/architecture.md

@@ -18,7 +18,8 @@
 - **没有 3D 虚拟人渲染**。`three` 仅用于粒子背景(`business-assistant-particle.ts`);
   **`src/three-libs/` 下有且只有一个模块:`asr/`,是语音识别,且在用**
   (PC 与移动端都 `import ASR from '@/three-libs/asr/index'`,点麦克风就走它)。
-  接口/事件/协议与已知坑见 [`reference/asr-realtime.md`](reference/asr-realtime.md)。
+  现行实现(**阿里云 NLS 直连**,2026-09-23 起)见 [`reference/asr-nls.md`](reference/asr-nls.md);
+  换之前那套(厂商 `asr_hub` 中转)的记录见 [`reference/asr-realtime.md`](reference/asr-realtime.md)。
   > 注:本文曾写「`src/three-libs/metamaker` 是未被引用的 SDK 包」——**那个目录不存在**,
   > 2026-09-18 核查时已更正。目录名 `three-libs` 有误导性:它与 three.js 无关。
 - **没有状态管理库**(无 Vuex / Pinia)。对话状态在 `useBusinessAssistantChat` 组合式函数内,

+ 2 - 0
harness/docs/reference/README.md

@@ -17,6 +17,8 @@
 | `DMS_COLUMNS.md` | 📄 **栏目现状** | 查 DMS 五个栏目的 columnId / 字段时 |
 | `DMS_MAPPING.md` | 📄 **表→栏目映射** | 做数据迁移、核对字段映射时 |
 | `current-api-call-sites.md` | 📋 **现状清单** | 想知道「还有哪些功能在用老接口 / 哪些是死代码」时 |
+| `asr-nls.md` | ✅ **现行实现(阿里云 NLS)** | 改实时语音识别之前(协议、事件、音频格式、验证方式) |
+| `asr-realtime.md` | 📦 **旧实现(厂商 SDK)** | 只在需要翻"换之前那套怎么工作"时读 |
 | `asr-realtime.md` | ✅ **现状记录 + 契约** | 改输入框语音(麦克风)相关代码之前 —— 含 SDK 的 WebSocket 协议、事件契约与 6 条已知坑 |
 
 > **DMS 三份文档的来路**:它们出自**另一个项目**

+ 124 - 0
harness/docs/reference/asr-nls.md

@@ -0,0 +1,124 @@
+# 实时语音识别(阿里云 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 直连**。
+
+前端这一侧的关键差别:**前端不再持有任何云凭据**。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`](../../../src/network/api/nls-token.ts) |
+| 协议层(**纯函数**,可被 harness 测) | [`src/three-libs/asr/nls-protocol.ts`](../../../src/three-libs/asr/nls-protocol.ts) |
+| 采集与连接(**对外契约与旧 SDK 一致**) | [`src/three-libs/asr/nls.ts`](../../../src/three-libs/asr/nls.ts) |
+| 服务端换 token(dev) | `vite.config.ts` 的 `/nls-token/` + [`harness/tools/nls-token.mjs`](../../tools/nls-token.mjs) |
+| 验证脚本 | [`harness/tools/verify-nls-realtime.mjs`](../../tools/verify-nls-realtime.mjs)(30 项,含真实识别) |
+| 探针(读原始帧用) | [`harness/tools/probe-nls-realtime.mjs`](../../tools/probe-nls-realtime.mjs) |
+
+## 2. 连接与指令
+
+```
+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` 里注释了):
+
+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`
+
+## 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 通路都已实现并**打真实网关验证通过**(30/30,含真实识别)
+- ⏳ **壳组件还没切过来**:`BusinessAssistantPC.vue` / `BusinessAssistantMobile.vue`
+  目前仍 `import ASR from '@/three-libs/asr/index'`(旧实现)。切换只需改 import
+  (契约一致),切完请在浏览器里点一次麦克风实测
+- ⏳ **生产要由后端/网关提供 `/nls-token/`**(技术债 #10),否则线上拿不到 token
+- 📌 旧实现(`asr/index.ts`)**保留**:切回它只改 import,且它承载着"厂商中转"那条退路

+ 4 - 0
harness/docs/reference/asr-realtime.md

@@ -1,5 +1,9 @@
 # 实时语音转文字(ASR)—— 前端接入文档
 
+> ⚠️ **2026-09-23 起:语音识别正在换成阿里云 NLS 直连** —— 现行实现看 [`asr-nls.md`](asr-nls.md)。
+> 本文档记录的是**换之前那套**(厂商 metamaker 的 `/common/asr_hub` 中转)。
+> 旧实现代码仍在仓库里(`src/three-libs/asr/index.ts`,是切回去的退路),所以这份记录仍然有效。
+>
 > **整理时间**:2026-09-23 | **依据**:仓库内的实现源码(不是厂商文档,也不是记忆)
 > **性质**:**现状记录 + 接口契约**。厂商 SDK 是本地源码(`src/three-libs/asr/index.ts`,578 行),
 > 所以这里写的每一条都能在代码里指到行;厂商侧若改了服务行为,**以实际观测为准**,

+ 48 - 0
harness/progress.md

@@ -4035,6 +4035,54 @@ payload 里含模型里**不存在**的列时,`addContent` 会忽略该列照
 **⚠️ 生产必须另做**:`/nls-token/` 是 **dev-only**(`apply:'serve'`)。生产要由后端/网关提供
 同样形态的端点(前端仍是同源 `GET /nls-token/`,secret 仍在服务端)。已登记为技术债 #10。
 
+### 续 33:语音识别换阿里云 NLS(第 3 步:协议 + 实现 + 真实验证)
+
+**先摸协议,且不凭记忆**:官方文档在 `help.aliyun.com`(WebFetch 被策略挡),
+所以按本项目的老规矩 —— **直接打真实网关读原始帧**(新增
+`harness/tools/probe-nls-realtime.mjs`,把上下行每一帧原样打出来)。
+
+**实测踩到的两条硬约束**(都已写进代码注释 + 断言):
+1. **每一条指令都要带 `appkey`** —— 只有 `StartTranscription` 带时,`StopTranscription` 回
+   `TaskFailed 40000002 Gateway:MESSAGE_INVALID:Missing message appkey!`
+2. **`task_id` 不能是空串** —— 回 `Gateway:MESSAGE_INVALID:Invalid task id ''!`
+3. 事件流实测确认:`TranscriptionStarted` → `TranscriptionResultChanged`(**中间结果,会被后续覆盖**)
+   → `SentenceEnd`(**整句定稿**,实测 "青浦区高新技术企业奖励是多少钱?")→ `TranscriptionCompleted`。
+   **拼接全文只能用 SentenceEnd**,中间结果只用于实时显示。
+
+**实现**(前端):
+- `src/three-libs/asr/nls-protocol.ts`:**纯函数协议层**(帧构建 / 事件折叠 / 16k PCM 编码)。
+  抽出来的理由:这是唯一"错了就静默不工作"的部分,纯函数才能被 harness 直接测
+- `src/three-libs/asr/nls.ts`:`NlsAsr` 类,**对外契约与旧 SDK 一致**
+  (`openMicroPhone/startRecord/openSocket/transform/close/clear` + `transform`/`error` 事件 +
+  暴露 `audioContext`/`source` 供 PC 画波形)→ 两个壳组件切过来只改 import
+- `src/network/api/nls-token.ts`:取 token(缓存到过期前 5 分钟 + 并发去重);
+  `src/utils/runtime-config.ts` 加 `getNlsTokenApiBaseUrl()`(同源 `/nls-token`,可用 env 覆盖)
+- 顺带修掉旧实现的一个隐患:**重采样按实际 `audioContext.sampleRate` 算**(旧实现写死 44100,
+  48k 设备上会送出"慢 8.8% 的 16k",见 asr-realtime.md §9.1)
+
+**验证**:新增 `harness/tools/verify-nls-realtime.mjs` —— **30/30**:
+- 【1】帧结构(appkey 每帧都带、task_id 不空、payload 参数、max_sentence_silence 合法区间)
+  + 事件折叠(中间结果覆盖 vs 定稿追加,用**实测抓到的真实载荷**做输入)
+- 【2】16k/16bit/单声道编码:48k 与 44.1k 输入各 100ms → 都是 3200 字节;小端与钳位
+- 【3】**真实端到端**:Windows TTS 造语音(`Microsoft Huihui Desktop`)→ 按 100ms 推流给真实网关
+  → 断言"识别期间有中间结果 + 定稿文本包含关键内容"。实测识别结果与所说话一致:
+  **"青浦区高新技术企业奖励是多少钱?"** ✓
+
+**这条测试立刻抓到两个真 bug**(都是我这次写的):① `buildStartTranscription` 不传 task_id 时
+发的是**空串** → 网关 40000002(已改成自动生成 32 位十六进制);② 我自己的断言索引写错
+(实际事件序列是 mid,mid,fin,mid)。变异测试:把 task_id 改回空串 → 6 项失败(含网关真实拒绝)。
+⚠️ 变异测试时我的还原命令走岔过一次(备份路径写成 `harness/tools/_p.bak`),
+文件一度停在坏状态 —— 靠第二次核对发现并还原;**教训:变异测试的还原要当场 grep 确认**。
+
+另跑:`npm run build` 通过、`vue-tsc` 新文件无类型错误、`validate-harness` 通过。
+
+**文档**:新增 `harness/docs/reference/asr-nls.md`(现行实现:连接/指令/事件/音频/契约/怎么验),
+`reference/README.md` 索引表与 `architecture.md` 都指向它,旧的 `asr-realtime.md` 顶部标注"这是换之前那套"。
+
+**⚠️ 下一步(未做,等用户确认)**:两个壳组件**还没切过来** —— 仍 `import ASR from '@/three-libs/asr/index'`
+(旧厂商实现)。切换只改 import(契约一致),但**必须由用户在浏览器点一次麦克风实测**才能算完成。
+旧实现保留,切回去也只改 import。
+
 ### 续 31:把实时语音转文字整理成接口文档(用户要求)
 
 **用户要求**:「将当前实时语音转文字的功能整理成一份 API 文档放在 harness 文件夹里面」。

+ 17 - 0
harness/tools/_entry-nls.ts

@@ -0,0 +1,17 @@
+/** esbuild 入口:把 NLS 协议层(纯函数)打包成 node 能 import 的 ESM */
+export {
+  NLS_NAMESPACE,
+  NLS_GATEWAY_URL,
+  NLS_SAMPLE_RATE,
+  NLS_FRAME_MS,
+  NLS_STATUS_SUCCESS,
+  createNlsId,
+  buildStartTranscription,
+  buildStopTranscription,
+  parseNlsMessage,
+  getNlsEventName,
+  describeNlsFailure,
+  createNlsTranscriptState,
+  reduceNlsTranscript,
+  encodePcm16kMono,
+} from '../../src/three-libs/asr/nls-protocol';

+ 104 - 0
harness/tools/nls-test-audio.mjs

@@ -0,0 +1,104 @@
+/**
+ * NLS 验证用的**测试音频**工具:造语音 + 转成 NLS 要的 16k/16bit/单声道 PCM。
+ *
+ * 为什么用系统 TTS 造:验证识别需要真语音,而仓库里没有音频素材。
+ * Windows 自带的 `System.Speech`(中文机器上一般有 `Microsoft Huihui Desktop`)
+ * 能把任意文本合成 wav —— 于是"说一句话 → 看识别结果"这条链路可以**全自动**验。
+ *
+ * 产物放在 `fixtures/_*`(按 .gitignore 不入库:它是产物,不是资料)。
+ */
+
+import { execFileSync } from 'node:child_process';
+import { existsSync, mkdirSync, readFileSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const HERE = dirname(fileURLToPath(import.meta.url));
+export const FIXTURE_DIR = join(HERE, 'fixtures');
+export const SPEECH_WAV = join(FIXTURE_DIR, '_nls-speech.wav');
+
+/** 合成一段中文语音到 `fixtures/_nls-speech.wav`,返回文件路径 */
+export const synthesizeSpeechToWav = (text, voice = 'Microsoft Huihui Desktop') => {
+  mkdirSync(FIXTURE_DIR, { recursive: true });
+  const escaped = String(text).replace(/'/g, "''");
+  const script = [
+    'Add-Type -AssemblyName System.Speech',
+    '$s = New-Object System.Speech.Synthesis.SpeechSynthesizer',
+    `try { $s.SelectVoice('${voice}') } catch { }`,
+    `$s.SetOutputToWaveFile('${SPEECH_WAV.replace(/\\/g, '\\\\')}')`,
+    `$s.Speak('${escaped}')`,
+    '$s.Dispose()',
+  ].join('; ');
+  execFileSync('powershell.exe', ['-NoProfile', '-Command', script], { stdio: 'inherit' });
+  return SPEECH_WAV;
+};
+
+/** 本机有没有可用的 TTS(没有就跳过真实识别那一段) */
+export const canSynthesizeSpeech = () => process.platform === 'win32';
+
+/** WAV(RIFF)→ `{ sampleRate, channels, bits, data }`;不是 RIFF 就返回 null(当作裸 PCM) */
+export const parseWav = (buffer) => {
+  if (buffer.length < 44 || buffer.toString('ascii', 0, 4) !== 'RIFF' || buffer.toString('ascii', 8, 12) !== 'WAVE') {
+    return null;
+  }
+  let offset = 12;
+  let fmt = null;
+  let data = null;
+  while (offset + 8 <= buffer.length) {
+    const id = buffer.toString('ascii', offset, offset + 4);
+    const size = buffer.readUInt32LE(offset + 4);
+    const body = buffer.subarray(offset + 8, offset + 8 + size);
+    if (id === 'fmt ') {
+      fmt = {
+        format: body.readUInt16LE(0),
+        channels: body.readUInt16LE(2),
+        sampleRate: body.readUInt32LE(4),
+        bits: body.readUInt16LE(14),
+      };
+    } else if (id === 'data') {
+      data = body;
+    }
+    offset += 8 + size + (size % 2); // 块按偶数字节对齐
+  }
+  return fmt && data ? { ...fmt, data } : null;
+};
+
+/** 线性插值重采样到 16k(int16 采样进、int16 采样出) */
+export const resampleTo16k = (samples, fromRate) => {
+  if (Math.round(fromRate) === 16000) return samples;
+  const count = Math.round((samples.length * 16000) / fromRate);
+  const out = new Int16Array(count);
+  const step = (samples.length - 1) / Math.max(count - 1, 1);
+  for (let i = 0; i < count; i++) {
+    const pos = i * step;
+    const before = Math.floor(pos);
+    const after = Math.min(Math.ceil(pos), samples.length - 1);
+    const ratio = pos - before;
+    out[i] = Math.round(samples[before] + (samples[after] - samples[before]) * ratio);
+  }
+  return out;
+};
+
+/**
+ * 读任意 WAV / 裸 PCM → **16k / 单声道 / 16bit 小端** 的 Buffer。
+ * 非 16bit 直接报错(宁可报错,也不要静默送错格式给网关)。
+ */
+export const readAudioAsPcm16kMono = (file) => {
+  if (!existsSync(file)) throw new Error(`音频文件不存在:${file}`);
+  const buffer = readFileSync(file);
+  const wav = parseWav(buffer);
+  if (!wav) return { pcm: buffer, note: '裸 PCM(按 16k/16bit/mono 直接用)' };
+  if (wav.bits !== 16) throw new Error(`只支持 16bit WAV,实际 ${wav.bits}bit`);
+
+  let samples = new Int16Array(wav.data.buffer, wav.data.byteOffset, Math.floor(wav.data.byteLength / 2));
+  if (wav.channels === 2) {
+    const mono = new Int16Array(Math.floor(samples.length / 2));
+    for (let i = 0; i < mono.length; i++) mono[i] = Math.round((samples[i * 2] + samples[i * 2 + 1]) / 2);
+    samples = mono;
+  }
+  const resampled = resampleTo16k(samples, wav.sampleRate);
+  return {
+    pcm: Buffer.from(resampled.buffer, resampled.byteOffset, resampled.byteLength),
+    note: `WAV ${wav.sampleRate}Hz/${wav.channels}ch/${wav.bits}bit → 重采样为 16k 单声道`,
+  };
+};

+ 176 - 0
harness/tools/probe-nls-realtime.mjs

@@ -0,0 +1,176 @@
+/**
+ * 探针:**直接连阿里云 NLS 实时语音识别网关**,把上下行的原始帧打出来看。
+ *
+ * 为什么要有它:NLS 的实时协议(`ws/v1`)细节以**实测**为准 —— 官方文档在
+ * `help.aliyun.com`(本机 WebFetch 被策略挡掉),而自述的帧结构一旦有一处不对,
+ * 网关返回的是 `TaskFailed` / 直接断连,光看封装层代码排查不出来
+ * (本项目在别处踩过同样的坑:协议问题必须读原始 SSE/WS 载荷)。
+ *
+ * 怎么跑(凭据从 .env.development.local 读,见 harness/tools/nls-token.mjs):
+ *   node harness/tools/probe-nls-realtime.mjs
+ *   node harness/tools/probe-nls-realtime.mjs --audio=<16k单声道16bit的pcm/raw文件>
+ *
+ * 默认不发音频(只发 start → 等待 → stop),用于确认**握手与事件名**;
+ * 带 `--audio=` 时按 ~100ms 一帧推流,用于确认**识别结果**。
+ */
+
+import { existsSync } from 'node:fs';
+import { randomUUID } from 'node:crypto';
+import {
+  NLS_LOCAL_ENV_FILE,
+  createNlsToken,
+  maskCredential,
+  readDotEnvFile,
+  readNlsCredentials,
+} from './nls-token.mjs';
+import { readAudioAsPcm16kMono, synthesizeSpeechToWav } from './nls-test-audio.mjs';
+
+
+const credentials = readNlsCredentials({ ...readDotEnvFile(NLS_LOCAL_ENV_FILE), ...process.env });
+if (!credentials.ak || !credentials.sk || !credentials.appkey) {
+  console.error('缺凭据:ALIYUN_NLS_AK / ALIYUN_NLS_SK / ALIYUN_NLS_APPKEY');
+  process.exit(2);
+}
+
+const GATEWAY = process.env.NLS_GATEWAY || 'wss://nls-gateway-cn-shanghai.aliyuncs.com/ws/v1';
+const audioArg = process.argv.find((a) => a.startsWith('--audio='));
+const sayArg = process.argv.find((a) => a.startsWith('--say='));
+const waitMs = Number((process.argv.find((a) => a.startsWith('--wait=')) || '').slice(7)) || 4000;
+
+const stamp = () => new Date().toISOString().slice(11, 23);
+const log = (...args) => console.log(`[${stamp()}]`, ...args);
+const raw = (value) => (typeof value === 'string' ? value : `(binary ${value?.byteLength ?? value?.length ?? '?'} bytes)`);
+
+let audioFile = audioArg ? audioArg.slice('--audio='.length) : '';
+if (sayArg) {
+  const text = sayArg.slice('--say='.length);
+  log(`用系统 TTS 合成语音:「${text}」`);
+  audioFile = synthesizeSpeechToWav(text);
+}
+if (audioFile && !existsSync(audioFile)) {
+  console.error(`音频文件不存在:${audioFile}`);
+  process.exit(2);
+}
+let audioPcm = null;
+if (audioFile) {
+  const { pcm, note } = readAudioAsPcm16kMono(audioFile);
+  audioPcm = pcm;
+  log(`音频:${audioFile}(${audioPcm.length} 字节 = ${(audioPcm.length / 32000).toFixed(2)} 秒)${note}`);
+}
+
+const { token, expireAt } = await createNlsToken(credentials);
+log(`token 到手(有效期至 ${new Date(expireAt).toLocaleString('zh-CN')})AK=${maskCredential(credentials.ak)}`);
+
+const url = `${GATEWAY}?token=${encodeURIComponent(token)}`;
+log('连接', url.replace(/token=[^&]+/, 'token=****'));
+
+const ws = new WebSocket(url);
+const newId = () => randomUUID().replace(/-/g, ''); // 文档要求 32 位十六进制
+const startMessageId = newId();
+const taskId = newId();
+let stopSent = false;
+
+const send = (payload) => {
+  const text = JSON.stringify(payload);
+  log('→ 发送', text.length > 400 ? `${text.slice(0, 400)}…` : text);
+  ws.send(text);
+};
+
+/**
+ * 发一条指令。⚠️ **每条指令都要带 appkey**(实测踩过:只有 start 带 appkey 时,
+ * stop 会回 `TaskFailed 40000002 Gateway:MESSAGE_INVALID:Missing message appkey!`),
+ * 所以这里统一注入 header,杜绝漏字段。
+ */
+const sendCommand = ({ name, task, payload }) => {
+  send({
+    header: {
+      appkey: credentials.appkey,
+      message_id: newId(),
+      task_id: taskId,
+      namespace: 'SpeechTranscriber',
+      task,
+      name,
+    },
+    ...(payload ? { payload } : {}),
+  });
+};
+
+const finish = (code) => {
+  try {
+    ws.close();
+  } catch {
+    /* ignore */
+  }
+  setTimeout(() => process.exit(code), 200);
+};
+
+const timer = setTimeout(() => {
+  log('⚠️ 超时退出(网关未在预期时间内给终态)');
+  finish(1);
+}, 25000);
+
+ws.onopen = () => {
+  log('✅ WebSocket 已连接');
+  sendCommand({
+    name: 'StartTranscription',
+    task: 'start',
+    payload: {
+      format: 'pcm',
+      sample_rate: 16000,
+      enable_intermediate_result: true,
+      enable_punctuation_prediction: true,
+      enable_inverse_text_normalization: true,
+      enable_voice_detection: true,
+      max_sentence_silence: 800,
+    },
+  });
+
+  if (audioPcm) {
+    // 按 100ms 一帧推流(16k/16bit/mono → 3200 字节 = 1600 采样)
+    const pcm = audioPcm;
+    const frame = 3200;
+    let offset = 0;
+    const pump = setInterval(() => {
+      if (offset >= pcm.length) {
+        clearInterval(pump);
+        log(`音频推完(${pcm.length} 字节)`);
+        if (!stopSent) {
+          stopSent = true;
+          sendCommand({ name: 'StopTranscription', task: 'stop' });
+        }
+        return;
+      }
+      const slice = pcm.subarray(offset, Math.min(offset + frame, pcm.length));
+      offset += slice.length;
+      if (ws.readyState === WebSocket.OPEN) ws.send(slice);
+    }, 100);
+  } else {
+    setTimeout(() => {
+      if (stopSent) return;
+      stopSent = true;
+      sendCommand({ name: 'StopTranscription', task: 'stop' });
+    }, waitMs);
+  }
+};
+
+ws.onmessage = (event) => {
+  const text = typeof event.data === 'string' ? event.data : '';
+  log('← 收到', raw(event.data));
+  if (text) {
+    try {
+      const body = JSON.parse(text);
+      const name = body?.header?.name || '(无 header.name)';
+      log(`   事件=${name} status=${body?.header?.status ?? body?.payload?.status ?? ''} 结果=${JSON.stringify(body?.payload?.result ?? '')}`);
+      if (name === 'TranscriptionCompleted') finish(0);
+      if (name === 'TaskFailed') finish(3);
+    } catch {
+      log('   (不是 JSON)');
+    }
+  }
+};
+
+ws.onerror = (event) => log('❌ WebSocket 错误', event?.message || event?.type || '');
+ws.onclose = (event) => {
+  clearTimeout(timer);
+  log(`连接关闭 code=${event.code} reason=${event.reason || ''}`);
+};

+ 241 - 0
harness/tools/verify-nls-realtime.mjs

@@ -0,0 +1,241 @@
+/**
+ * 验证「实时语音识别换成阿里云 NLS」这条链路。
+ *
+ * 分两段:
+ *   【1】纯协议(不需要网络/凭据):帧结构、事件折叠、16k PCM 编码
+ *   【2】真实端到端(需要凭据 + Windows TTS):说一句话 → 看识别结果
+ *        —— 用的是**前端同一份协议代码**(`src/three-libs/asr/nls-protocol.ts`),
+ *        所以它绿了就意味着前端那套帧真的被网关接受
+ *
+ * 怎么跑(先打包协议层):
+ *   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
+ *
+ * 凭据从 `.env.development.local` 读(见 harness/tools/nls-token.mjs);
+ * 没有凭据或没有 TTS 时,【2】会**明确跳过**而不是假装通过。
+ */
+
+import { existsSync } from 'node:fs';
+import { createNlsToken, NLS_LOCAL_ENV_FILE, readDotEnvFile, readNlsCredentials } from './nls-token.mjs';
+import { canSynthesizeSpeech, readAudioAsPcm16kMono, synthesizeSpeechToWav } from './nls-test-audio.mjs';
+
+const {
+  NLS_FRAME_MS,
+  NLS_GATEWAY_URL,
+  NLS_SAMPLE_RATE,
+  buildStartTranscription,
+  buildStopTranscription,
+  createNlsTranscriptState,
+  describeNlsFailure,
+  encodePcm16kMono,
+  getNlsEventName,
+  parseNlsMessage,
+  reduceNlsTranscript,
+} = await import('./_nls.mjs');
+
+let pass = 0;
+let fail = 0;
+const check = (name, ok, extra = '') => {
+  if (ok) {
+    pass++;
+    console.log('  ok   ' + name);
+  } else {
+    fail++;
+    console.log('  FAIL ' + name + '  ' + extra);
+  }
+};
+
+// ── 1. 纯协议 ───────────────────────────────────────────────────────────────
+console.log('\n【1】帧结构与事件折叠(纯函数,用前端同一份实现)');
+{
+  const start = JSON.parse(buildStartTranscription({ appkey: 'APPKEY', taskId: 'TASK', messageId: 'MSG' }));
+  check('StartTranscription:namespace 固定 SpeechTranscriber',
+    start.header.namespace === 'SpeechTranscriber' && start.header.name === 'StartTranscription');
+  check('StartTranscription:header 带 appkey(网关要求)', start.header.appkey === 'APPKEY');
+  check('StartTranscription:task=start,显式传入的 message_id/task_id 原样带上',
+    start.header.task === 'start' && start.header.message_id === 'MSG' && start.header.task_id === 'TASK');
+
+  // **不传 task_id 时必须自己生成**(实测:空串会被网关拒
+  // `TaskFailed 40000002 Gateway:MESSAGE_INVALID:Invalid task id ''!`)
+  const auto = JSON.parse(buildStartTranscription({ appkey: 'APPKEY' }));
+  check('不传 id 时自动生成 32 位十六进制的 message_id / task_id(不能是空串)',
+    /^[0-9a-f]{32}$/.test(auto.header.message_id) && /^[0-9a-f]{32}$/.test(auto.header.task_id),
+    JSON.stringify([auto.header.message_id, auto.header.task_id]));
+  check('两次生成的 task_id 不重复', auto.header.task_id !== JSON.parse(buildStartTranscription({ appkey: 'APPKEY' })).header.task_id);
+  check('payload:16k / pcm / 开中间结果 / 开标点 / 开 VAD',
+    start.payload.format === 'pcm' &&
+      start.payload.sample_rate === 16000 &&
+      start.payload.enable_intermediate_result === true &&
+      start.payload.enable_punctuation_prediction === true &&
+      start.payload.enable_voice_detection === true);
+  check('payload:max_sentence_silence 落在网关允许的 200~2000',
+    start.payload.max_sentence_silence >= 200 && start.payload.max_sentence_silence <= 2000,
+    String(start.payload.max_sentence_silence));
+
+  const stop = JSON.parse(buildStopTranscription({ appkey: 'APPKEY', taskId: 'TASK', messageId: 'MSG2' }));
+  // 实测踩过的坑:只有 start 带 appkey 时,stop 会回
+  // TaskFailed 40000002 Gateway:MESSAGE_INVALID:Missing message appkey!
+  check('**StopTranscription 也带 appkey**(漏了网关会 40000002)', stop.header.appkey === 'APPKEY');
+  check('StopTranscription:task=stop + task_id 与 start 一致', stop.header.task === 'stop' && stop.header.task_id === 'TASK');
+
+  // 事件折叠:载荷取自探针打真实网关抓到的原文
+  let state = createNlsTranscriptState();
+  const events = [];
+  const feed = (raw) => {
+    const { state: next, event } = reduceNlsTranscript(state, parseNlsMessage(raw));
+    state = next;
+    if (event) events.push(event);
+  };
+  feed(JSON.stringify({ header: { name: 'TranscriptionStarted', status: 20000000 } }));
+  feed(JSON.stringify({ header: { name: 'TranscriptionResultChanged' }, payload: { index: 1, result: '青浦区高新' } }));
+  feed(JSON.stringify({ header: { name: 'TranscriptionResultChanged' }, payload: { index: 1, result: '青浦区高新技术企' } }));
+  feed(JSON.stringify({ header: { name: 'SentenceEnd' }, payload: { index: 1, result: '青浦区高新技术企业奖励是多少钱?' } }));
+  feed(JSON.stringify({ header: { name: 'TranscriptionResultChanged' }, payload: { index: 2, result: '我想问' } }));
+  feed(JSON.stringify({ header: { name: 'TranscriptionCompleted', status: 20000000 } }));
+
+  // 事件序列:mid(本句中间) → mid(本句中间) → fin(定稿) → mid(下一句中间)
+  check('TranscriptionStarted / Completed 不产出文本事件(4 条文本事件 = 2 mid + 1 fin + 1 mid)',
+    events.length === 4 && events.map((e) => e.type).join(',') === 'mid,mid,fin,mid',
+    JSON.stringify(events.map((e) => e.type)));
+  check('中间结果 → type=mid,transformText = 已定稿全文 + 本句中间结果(边说边出字)',
+    events[0].type === 'mid' && events[0].transformText === '青浦区高新',
+    JSON.stringify(events[0]));
+  check('中间结果会被后续结果**覆盖**(不是追加)',
+    events[1].type === 'mid' && events[1].transformText === '青浦区高新技术企',
+    JSON.stringify(events[1]));
+  check('SentenceEnd(定稿)→ type=fin,且**追加**不覆盖',
+    events[2].type === 'fin' && events[2].transformText === '青浦区高新技术企业奖励是多少钱?',
+    JSON.stringify(events[2]));
+  check('下一句的中间结果前缀带上已定稿的那句',
+    events[3].type === 'mid' && events[3].transformText === '青浦区高新技术企业奖励是多少钱?我想问',
+    JSON.stringify(events[3]));
+  check('最终定稿全文只由 SentenceEnd 拼成(中间结果不污染)',
+    state.finalText === '青浦区高新技术企业奖励是多少钱?', JSON.stringify(state.finalText));
+
+  check('TaskFailed 的错误描述带 status 与 status_text(能直接给用户看)',
+    describeNlsFailure(parseNlsMessage(JSON.stringify({
+      header: { name: 'TaskFailed', status: 40000002, status_text: 'Gateway:MESSAGE_INVALID:Missing message appkey!' },
+    }))).includes('40000002') &&
+      describeNlsFailure(parseNlsMessage('{"header":{"status":40000002}}')).length > 0);
+  check('非法 JSON 不抛异常(忽略即可)', parseNlsMessage('not json') === null && getNlsEventName(null) === '');
+}
+
+console.log('\n【2】16k / 16bit / 单声道 编码');
+{
+  // 100ms 的 48k 输入 → 应为 1600 采样 = 3200 字节(旧 SDK 写死 44100,这里是按实际采样率算的)
+  const input48k = new Float32Array(4800);
+  const encoded48k = encodePcm16kMono(input48k, 48000);
+  check('48k → 16k:100ms 得到 3200 字节(1600 采样 × 2)', encoded48k.length === 3200, String(encoded48k.length));
+
+  const encoded44k = encodePcm16kMono(new Float32Array(4410), 44100);
+  check('44.1k → 16k:同样 100ms 得到 3200 字节(不再写死 44100 也能对)',
+    encoded44k.length === 3200, String(encoded44k.length));
+
+  // 值域与字节序:满幅正弦的第一个正峰应是 0x7FFF(小端 0xFF 0x7F)
+  const half = new Float32Array(1600);
+  for (let i = 0; i < half.length; i++) half[i] = Math.sin((2 * Math.PI * i) / 100);
+  const pcm = encodePcm16kMono(half, NLS_SAMPLE_RATE);
+  const view = new DataView(pcm.buffer, pcm.byteOffset, pcm.byteLength);
+  const firstPeak = view.getInt16(2 * 24, true); // 第 25 个采样 ≈ sin(π/2)=1 附近
+  check('int16 小端写入(正值取 0x7fff 附近)', firstPeak > 32000, String(firstPeak));
+  check('超出 ±1 的采样被钳位(不溢出成噪声)',
+    encodePcm16kMono(new Float32Array([2, -2]), NLS_SAMPLE_RATE).length === 4);
+  check('帧长与实时节奏自洽:NLS_FRAME_MS × 16k × 2 字节 = 3200',
+    (NLS_SAMPLE_RATE * 2 * NLS_FRAME_MS) / 1000 === 3200);
+  check('网关地址是上海地域的 ws/v1 端点', NLS_GATEWAY_URL.endsWith('/ws/v1') && NLS_GATEWAY_URL.startsWith('wss://'));
+}
+
+// ── 3. 真实端到端(打真实网关)───────────────────────────────────────────────
+console.log('\n【3】打真实网关:说一句话 → 看识别结果');
+const credentials = readNlsCredentials({ ...readDotEnvFile(NLS_LOCAL_ENV_FILE), ...process.env });
+if (!credentials.ak || !credentials.sk || !credentials.appkey) {
+  console.log('  ⏭  跳过:没配 NLS 凭据(.env.development.local 的 ALIYUN_NLS_AK / SK / APPKEY)');
+} else if (!canSynthesizeSpeech()) {
+  console.log('  ⏭  跳过:真实识别这一段需要 Windows 自带 TTS 造语音');
+} else {
+  const SPOKEN = '青浦区高新技术企业奖励是多少钱';
+  const wav = synthesizeSpeechToWav(SPOKEN);
+  const { pcm, note } = readAudioAsPcm16kMono(wav);
+  console.log(`  (TTS 音频 ${pcm.length} 字节 ≈ ${(pcm.length / 32000).toFixed(2)}s;${note})`);
+
+  const { token } = await createNlsToken(credentials);
+  const socket = new WebSocket(`${NLS_GATEWAY_URL}?token=${encodeURIComponent(token)}`);
+
+  const seen = { started: false, completed: false, failed: '', mids: 0, fins: 0 };
+  let state = createNlsTranscriptState();
+  const feed = (raw) => {
+    const message = parseNlsMessage(raw);
+    const name = getNlsEventName(message);
+    if (name === 'TranscriptionStarted') seen.started = true;
+    if (name === 'TranscriptionCompleted') seen.completed = true;
+    if (name === 'TaskFailed') seen.failed = describeNlsFailure(message);
+    const { state: next, event } = reduceNlsTranscript(state, message);
+    state = next;
+    if (event) {
+      if (event.type === 'mid') seen.mids++;
+      if (event.type === 'fin') seen.fins++;
+    }
+  };
+
+  // 用**前端同一份**帧构建器:发出去的就是产品会发的那一帧
+  const startFrame = buildStartTranscription({ appkey: credentials.appkey });
+  const taskId = JSON.parse(startFrame).header.task_id;
+
+  const finished = new Promise((resolve) => {
+    let stopSent = false;
+    const stop = () => {
+      if (stopSent) return;
+      stopSent = true;
+      socket.send(buildStopTranscription({ appkey: credentials.appkey, taskId }));
+    };
+
+    socket.onopen = () => {
+      socket.send(startFrame);
+      let offset = 0;
+      const frame = 3200; // 100ms
+      const pump = setInterval(() => {
+        if (offset >= pcm.length) {
+          clearInterval(pump);
+          stop();
+          return;
+        }
+        if (socket.readyState !== WebSocket.OPEN) return;
+        socket.send(pcm.subarray(offset, Math.min(offset + frame, pcm.length)));
+        offset += frame;
+      }, NLS_FRAME_MS);
+    };
+    socket.onmessage = (event) => {
+      feed(event.data);
+      if (seen.completed || seen.failed) {
+        socket.close();
+        resolve();
+      }
+    };
+    socket.onerror = () => resolve();
+    setTimeout(() => {
+      try {
+        socket.close();
+      } catch {
+        /* ignore */
+      }
+      resolve();
+    }, 30000);
+  });
+
+  await finished;
+
+  check('收到 TranscriptionStarted(token/appkey 有效)', seen.started, seen.failed || '未收到');
+  check('没有 TaskFailed', !seen.failed, seen.failed);
+  check('**识别期间就收到中间结果(边说边出字)**', seen.mids > 0, `mids=${seen.mids}`);
+  check('收到定稿的 SentenceEnd', seen.fins > 0, `fins=${seen.fins}`);
+  check('收到 TranscriptionCompleted(收尾干净)', seen.completed, seen.failed || '未收到');
+  const text = state.finalText;
+  console.log(`  (识别结果:${JSON.stringify(text)})`);
+  check('识别出的文本包含关键内容(青浦 / 奖励)',
+    text.includes('青浦') && text.includes('奖励'), JSON.stringify(text));
+  check('识别结果长度合理(≥ 8 字)', text.length >= 8, String(text.length));
+}
+
+console.log(`\n===== 通过 ${pass} 项,失败 ${fail} 项 =====`);
+process.exit(fail ? 1 : 0);

+ 73 - 0
src/network/api/nls-token.ts

@@ -0,0 +1,73 @@
+/**
+ * 取阿里云 NLS 的 token(实时语音识别用)。
+ *
+ * **前端不签 token**:签名要 AccessKeySecret,而前端 bundle 是公开的 ——
+ * 凭据只在服务端(开发环境是 vite 的 `/nls-token/`,见 `harness/tools/nls-token.mjs`),
+ * 前端只拿到 `{ token, expireAt, appkey }`。
+ *
+ * 缓存策略:token 24 小时有效,缓存到 `expireAt - 5 分钟`;
+ * 并发调用共用同一个在途请求(说话时可能连着触发几次)。
+ */
+
+import { getNlsTokenApiBaseUrl } from "@/utils/runtime-config";
+
+export interface NlsTokenResult {
+  token: string;
+  /** 过期时间(epoch 毫秒) */
+  expireAt: number;
+  /** 识别请求要带的 appkey;由端点下发,前端不内置 */
+  appkey: string;
+}
+
+/** 提前多久视为过期(与服务端刷新节奏一致) */
+const REFRESH_AHEAD_MS = 5 * 60 * 1000;
+
+let cached: NlsTokenResult | null = null;
+let inflight: Promise<NlsTokenResult> | null = null;
+
+const isValid = () => !!cached && cached.expireAt - Date.now() > REFRESH_AHEAD_MS;
+
+export const fetchNlsToken = async (): Promise<NlsTokenResult> => {
+  if (isValid()) {
+    return cached as NlsTokenResult;
+  }
+  if (inflight) {
+    return inflight;
+  }
+
+  inflight = (async () => {
+    const response = await fetch(`${getNlsTokenApiBaseUrl()}/`, { method: "GET" });
+    let body: any = null;
+    try {
+      body = await response.json();
+    } catch {
+      /* 非 JSON 时下面按状态码报错 */
+    }
+
+    if (!response.ok) {
+      // 服务端会给出可读原因(未配凭据 / 换 token 失败),直接透传给用户看
+      throw new Error(body?.error || `获取语音识别 token 失败(HTTP ${response.status})`);
+    }
+    if (!body?.token) {
+      throw new Error("获取语音识别 token 失败:响应里没有 token");
+    }
+
+    cached = {
+      token: String(body.token),
+      expireAt: Number(body.expireAt) || Date.now() + REFRESH_AHEAD_MS,
+      appkey: String(body.appkey || ""),
+    };
+    return cached;
+  })();
+
+  try {
+    return await inflight;
+  } finally {
+    inflight = null;
+  }
+};
+
+/** 丢掉缓存(排查用:怀疑 token 失效时强制重取) */
+export const clearNlsTokenCache = (): void => {
+  cached = null;
+};

+ 198 - 0
src/three-libs/asr/nls-protocol.ts

@@ -0,0 +1,198 @@
+/**
+ * 阿里云 NLS「实时语音识别」的**协议层**(纯函数,不碰 DOM / WebSocket)。
+ *
+ * 单独抽出来的原因:① 帧结构与事件映射是这个改造里唯一"错了就静默不工作"的部分,
+ * 抽成纯函数就能被 harness 直接测(`harness/tools/verify-nls-realtime.mjs`);
+ * ② `src/three-libs/asr/nls.ts` 只负责音频采集与连接,读起来更清楚。
+ *
+ * 契约来源:**实测**(`harness/tools/probe-nls-realtime.mjs` 打真实网关,
+ * 逐帧看过原始载荷;文档侧要点见 progress.md 续 33)。两条踩过的坑:
+ *   1. **每一条指令都要带 `appkey`** —— 只有 start 带时,stop 会回
+ *      `TaskFailed 40000002 Gateway:MESSAGE_INVALID:Missing message appkey!`
+ *   2. 中间结果(`TranscriptionResultChanged`)**会被后续结果覆盖/重叠**,
+ *      拼接全文只能用 `SentenceEnd` 的定稿结果
+ */
+
+export const NLS_NAMESPACE = "SpeechTranscriber";
+
+/** 网关地址(上海地域;token 放在 query 上) */
+export const NLS_GATEWAY_URL = "wss://nls-gateway-cn-shanghai.aliyuncs.com/ws/v1";
+
+/** 音频按 16k / 16bit / 单声道送(与 `StartTranscription.payload` 里的声明一致) */
+export const NLS_SAMPLE_RATE = 16000;
+
+/** 一帧送多少毫秒的音频。100ms 接近实时,且不会把网关灌爆(文档要求按实时速率发) */
+export const NLS_FRAME_MS = 100;
+
+/** 32 位十六进制 ID(文档要求 message_id / task_id 的形态) */
+export const createNlsId = (): string => {
+  const bytes = new Uint8Array(16);
+  if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") {
+    crypto.getRandomValues(bytes);
+  } else {
+    for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256);
+  }
+  return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
+};
+
+export interface NlsStartOptions {
+  appkey: string;
+  taskId?: string;
+  messageId?: string;
+  /** 端点静音断句阈值(毫秒,200~2000)。默认给 800:说一句停一下就能出定稿 */
+  maxSentenceSilence?: number;
+}
+
+/**
+ * `StartTranscription` 帧。`enable_intermediate_result` 必须开,否则没有"边说边出字"。
+ *
+ * ⚠️ `task_id` **不能为空**:实测网关会回
+ * `TaskFailed 40000002 Gateway:MESSAGE_INVALID:Invalid task id ''!` ——
+ * 所以不传时这里自己生成一个(32 位十六进制),调用方拿返回值里的 task_id 复用到 stop 帧。
+ */
+export const buildStartTranscription = (options: NlsStartOptions) =>
+  JSON.stringify({
+    header: {
+      appkey: options.appkey,
+      message_id: options.messageId || createNlsId(),
+      task_id: options.taskId || createNlsId(),
+      namespace: NLS_NAMESPACE,
+      task: "start",
+      name: "StartTranscription",
+    },
+    payload: {
+      format: "pcm",
+      sample_rate: NLS_SAMPLE_RATE,
+      enable_intermediate_result: true,
+      enable_punctuation_prediction: true,
+      enable_inverse_text_normalization: true,
+      enable_voice_detection: true,
+      max_sentence_silence: options.maxSentenceSilence ?? 800,
+    },
+  });
+
+/** `StopTranscription` 帧 —— ⚠️ appkey 不能省(见文件头注释的坑 1) */
+export const buildStopTranscription = (options: { appkey: string; taskId: string; messageId?: string }) =>
+  JSON.stringify({
+    header: {
+      appkey: options.appkey,
+      message_id: options.messageId || createNlsId(),
+      task_id: options.taskId,
+      namespace: NLS_NAMESPACE,
+      task: "stop",
+      name: "StopTranscription",
+    },
+  });
+
+export interface NlsServerMessage {
+  header?: { name?: string; status?: number; status_text?: string; message_id?: string; task_id?: string };
+  payload?: { index?: number; time?: number; result?: string; confidence?: number; status?: number; message?: string };
+}
+
+/** 解析下行文本帧;不是合法 JSON 就返回 null(二进制帧等一律忽略) */
+export const parseNlsMessage = (raw: unknown): NlsServerMessage | null => {
+  if (typeof raw !== "string" || !raw.trim()) return null;
+  try {
+    const parsed = JSON.parse(raw);
+    return parsed && typeof parsed === "object" ? (parsed as NlsServerMessage) : null;
+  } catch {
+    return null;
+  }
+};
+
+export const getNlsEventName = (message: NlsServerMessage | null): string => message?.header?.name || "";
+
+/** 网关给的成功码 */
+export const NLS_STATUS_SUCCESS = 20000000;
+
+/** 把网关的错误说成人话(用于 toast / 日志) */
+export const describeNlsFailure = (message: NlsServerMessage | null): string => {
+  const status = message?.header?.status ?? message?.payload?.status;
+  const text = message?.header?.status_text || message?.payload?.message || "";
+  return `语音识别失败(${status ?? "未知"})${text ? `:${text}` : ""}`;
+};
+
+export interface NlsTranscriptState {
+  /** 已经**定稿**的句子拼起来的全文 */
+  finalText: string;
+}
+
+export const createNlsTranscriptState = (): NlsTranscriptState => ({ finalText: "" });
+
+export interface NlsTransformPayload {
+  /** 与旧 SDK 的事件负载保持一致,壳组件不用改 */
+  type: "mid" | "fin";
+  current: string;
+  transformText: string;
+  command: "";
+}
+
+/**
+ * 把一条下行事件折叠进识别状态,产出要发给界面的事件(没有可发内容时返回 null)。
+ *
+ * | 网关事件 | 产出 |
+ * |---|---|
+ * | `TranscriptionResultChanged` | `mid`:`transformText` = 已定稿全文 + 本句中间结果 |
+ * | `SentenceEnd` | `fin`:把本句**定稿**追加进全文,`transformText` = 新全文 |
+ * | `SentenceBegin` / 其它 | null(状态不变) |
+ *
+ * 注意 `SentenceEnd.payload.result` 是**整句**(实测:"青浦区高新技术企业奖励是多少钱?"),
+ * 所以是追加而不是替换。
+ */
+export const reduceNlsTranscript = (
+  state: NlsTranscriptState,
+  message: NlsServerMessage | null
+): { state: NlsTranscriptState; event: NlsTransformPayload | null } => {
+  const name = getNlsEventName(message);
+  const result = String(message?.payload?.result ?? "");
+
+  if (name === "TranscriptionResultChanged") {
+    if (!result) return { state, event: null };
+    return {
+      state,
+      event: { type: "mid", current: result, transformText: state.finalText + result, command: "" },
+    };
+  }
+
+  if (name === "SentenceEnd") {
+    if (!result) return { state, event: null };
+    const next = { finalText: state.finalText + result };
+    return { state: next, event: { type: "fin", current: result, transformText: next.finalText, command: "" } };
+  }
+
+  return { state, event: null };
+};
+
+/**
+ * Float32 采样 → 16k / 16bit / 单声道 的小端 PCM 字节。
+ *
+ * ⚠️ 与旧 SDK 的 `to16kHZ()` 的区别:**采样率按实际传入的 `fromRate` 算**
+ * (旧实现把输入写死成 44100,设备实际是 48000 时会送出"慢 8% 的 16k",
+ * 见 `harness/docs/reference/asr-realtime.md` §9.1)—— 这里是新实现,不再沿用那个假设。
+ */
+export const encodePcm16kMono = (input: Float32Array, fromRate: number): Uint8Array => {
+  const rate = Number.isFinite(fromRate) && fromRate > 0 ? fromRate : NLS_SAMPLE_RATE;
+  let samples: Float32Array = input;
+
+  if (Math.round(rate) !== NLS_SAMPLE_RATE) {
+    const count = Math.round((input.length * NLS_SAMPLE_RATE) / rate);
+    const resampled = new Float32Array(count);
+    const step = (input.length - 1) / Math.max(count - 1, 1);
+    for (let i = 0; i < count; i++) {
+      const pos = i * step;
+      const before = Math.floor(pos);
+      const after = Math.min(Math.ceil(pos), input.length - 1);
+      const ratio = pos - before;
+      resampled[i] = input[before] + (input[after] - input[before]) * ratio;
+    }
+    samples = resampled;
+  }
+
+  const bytes = new Uint8Array(samples.length * 2);
+  const view = new DataView(bytes.buffer);
+  for (let i = 0; i < samples.length; i++) {
+    const clamped = Math.max(-1, Math.min(1, samples[i]));
+    view.setInt16(i * 2, clamped < 0 ? clamped * 0x8000 : clamped * 0x7fff, true);
+  }
+  return bytes;
+};

+ 303 - 0
src/three-libs/asr/nls.ts

@@ -0,0 +1,303 @@
+/**
+ * 实时语音识别(阿里云 NLS 版)—— 与旧厂商 SDK(`./index.ts`)**对外契约一致**:
+ *
+ *   new ASR(appKey?, CONSTANTS?)  ·  openMicroPhone()  ·  startRecord()  ·  openSocket()
+ *   ·  transform()  ·  close()  ·  clear()  ·  事件 `transform` / `error`
+ *
+ * 为什么保持契约:两个壳组件(`BusinessAssistantPC.vue` / `BusinessAssistantMobile.vue`)
+ * 里的 `toggleVoiceInput` / `stopVoiceInput` 一行都不用改,切回旧实现也只改 import。
+ *
+ * 与旧实现的实质差别:
+ *   - 凭据:**前端不持有任何阿里云凭据**,token + appkey 由服务端端点下发
+ *     (`src/network/api/nls-token.ts` → dev 是 vite 的 `/nls-token/`)
+ *   - 重采样按**实际采样率**算(旧实现写死 44100,见 asr-realtime.md §9.1)
+ *   - 定稿只认 `SentenceEnd`(中间结果会重叠,不能拼)
+ *   - 采样位深/声道:仍然 16k / 16bit / 单声道
+ *
+ * 协议细节与实测依据见 `./nls-protocol.ts` 与 `harness/docs/reference/asr-nls.md`。
+ */
+
+import { BaseEvent } from "@/utils/base-event";
+import { fetchNlsToken } from "@/network/api/nls-token";
+import {
+  NLS_FRAME_MS,
+  NLS_GATEWAY_URL,
+  NLS_SAMPLE_RATE,
+  NLS_STATUS_SUCCESS,
+  buildStartTranscription,
+  buildStopTranscription,
+  createNlsId,
+  createNlsTranscriptState,
+  describeNlsFailure,
+  encodePcm16kMono,
+  getNlsEventName,
+  parseNlsMessage,
+  reduceNlsTranscript,
+} from "./nls-protocol";
+
+/** 收尾时最多等网关多久(超过就用手上已有的文本收场,不让"停止"按钮卡住) */
+const STOP_TIMEOUT_MS = 3000;
+/** 建连/开始识别最多等多久 */
+const START_TIMEOUT_MS = 8000;
+
+export default class NlsAsr extends BaseEvent {
+  /** 构造参数里的第一个仍是 appKey,但 NLS 版的 appkey 由端点下发,这里只作兜底 */
+  appKey: string;
+  CONSTANTS: MediaStreamConstraints;
+
+  stream: MediaStream | null = null;
+  audioContext: AudioContext | null = null;
+  source: MediaStreamAudioSourceNode | null = null;
+  processor: ScriptProcessorNode | null = null;
+
+  /** 已定稿全文(`transform()` 的返回值来源) */
+  private transcript = createNlsTranscriptState();
+  private socket: WebSocket | null = null;
+  private taskId = "";
+  private appkey = "";
+  private pendingAudio: Uint8Array[] = [];
+  private sendTimer: number | null = null;
+  private processing = false;
+  private stopping = false;
+  private startResolve: (() => void) | null = null;
+  private startReject: ((error: Error) => void) | null = null;
+  private stopResolve: (() => void) | null = null;
+
+  constructor(appKey = "", CONSTANTS: MediaStreamConstraints = { audio: true, video: false }) {
+    super();
+    this.appKey = appKey;
+    this.CONSTANTS = CONSTANTS;
+  }
+
+  /** 申请麦克风。失败抛 `Error`(调用方 catch 后 toast)——与旧实现的语义一致 */
+  async openMicroPhone(): Promise<boolean> {
+    try {
+      this.stream = await navigator.mediaDevices.getUserMedia(this.CONSTANTS);
+    } catch (error) {
+      console.warn("[nls] 获取麦克风失败", error);
+      // 区分"用户拒绝"与"设备异常":前者要给可操作的提示
+      if ((error as DOMException)?.name === "NotAllowedError") {
+        throw new Error("麦克风权限被拒绝,请在浏览器或系统设置里允许后重试");
+      }
+      throw new Error("获取麦克风流失败,请检查麦克风是否正常工作");
+    }
+    return true;
+  }
+
+  /**
+   * 开始采集音频。与旧实现一样**必须在 `openSocket()` 之前调用**。
+   * 采集到的 PCM 先缓存,连上后再按实时节奏推给网关。
+   */
+  startRecord(): void {
+    this.pendingAudio = [];
+    this.transcript = createNlsTranscriptState();
+    this.processing = true;
+    this.stopping = false;
+
+    if (!this.stream) {
+      this._eventbus.emit("error", { message: "未获取到麦克风流" });
+      return;
+    }
+
+    this.audioContext = new window.AudioContext();
+    this.source = this.audioContext.createMediaStreamSource(this.stream);
+    // ScriptProcessorNode 已废弃,但仓库现有的波形绘制依赖 audioContext/source,
+    // NLS 与旧实现共用同一套采集方式,避免"换语音识别顺带换了采集"这种大改
+    this.processor = this.audioContext.createScriptProcessor(0, 1, 1);
+    this.processor.connect(this.audioContext.destination);
+    this.source.connect(this.processor);
+    const sampleRate = this.audioContext.sampleRate;
+    this.processor.onaudioprocess = (event: AudioProcessingEvent) => {
+      if (!this.processing || !this.stream || !this.stream.active) return;
+      const channel = event.inputBuffer.getChannelData(0);
+      this.pendingAudio.push(encodePcm16kMono(channel, sampleRate));
+    };
+  }
+
+  /** 取 token → 连网关 → 发 StartTranscription → 等 TranscriptionStarted */
+  async openSocket(): Promise<void> {
+    const { token, appkey } = await fetchNlsToken();
+    this.appkey = appkey || this.appKey;
+    this.taskId = createNlsId();
+
+    await new Promise<void>((resolve, reject) => {
+      this.startResolve = resolve;
+      this.startReject = reject;
+
+      const url = `${NLS_GATEWAY_URL}?token=${encodeURIComponent(token)}`;
+      const socket = new WebSocket(url);
+      this.socket = socket;
+
+      const startTimer = window.setTimeout(() => {
+        reject(new Error("语音识别连接超时(未收到 TranscriptionStarted)"));
+        this.closeSocket();
+      }, START_TIMEOUT_MS);
+
+      const settle = () => window.clearTimeout(startTimer);
+
+      socket.onopen = () => {
+        socket.send(buildStartTranscription({ appkey: this.appkey, taskId: this.taskId }));
+      };
+
+      socket.onmessage = (event: MessageEvent<string>) => {
+        const message = parseNlsMessage(event.data);
+        const name = getNlsEventName(message);
+        if (!name) return;
+
+        if (name === "TranscriptionStarted") {
+          settle();
+          this.startFlushTimer();
+          this.startResolve?.();
+          return;
+        }
+        if (name === "TaskFailed") {
+          const error = new Error(describeNlsFailure(message));
+          settle();
+          this._eventbus.emit("error", { message: error.message });
+          this.startReject?.(error);
+          return;
+        }
+
+        const { state, event: payload } = reduceNlsTranscript(this.transcript, message);
+        this.transcript = state;
+        if (payload) this._eventbus.emit("transform", payload);
+
+        if (name === "TranscriptionCompleted") {
+          this.stopResolve?.();
+        }
+      };
+
+      socket.onerror = () => {
+        settle();
+        const error = new Error("语音识别连接出错");
+        this._eventbus.emit("error", { message: error.message });
+        this.startReject?.(error);
+      };
+
+      socket.onclose = () => {
+        settle();
+        // 还没开始就断了 → 让 openSocket 的 await 失败,别静默卡住
+        this.startReject?.(new Error("语音识别连接已关闭"));
+        this.stopResolve?.();
+      };
+    });
+  }
+
+  /**
+   * 结束一轮:停麦克风 → 把余下的音频发完 → 发 StopTranscription → 等 TranscriptionCompleted。
+   * 返回**累计定稿文本**(与旧实现的 `{text, command}` 形态一致)。
+   */
+  async transform(): Promise<{ text: string; command: string }> {
+    this.stopping = true;
+    this.processing = false;
+    this.stopSendTimer();
+
+    if (this.stream) {
+      this.stream.getTracks().forEach((track) => track.stop());
+    }
+    this.source?.disconnect();
+    this.processor?.disconnect();
+
+    // 余下的音频按帧发完(每帧等一个周期,别把网关灌爆)
+    const frames = this.pendingAudio;
+    this.pendingAudio = [];
+    for (const frame of frames) {
+      if (this.socket?.readyState !== WebSocket.OPEN) break;
+      this.socket.send(frame);
+      await new Promise((r) => window.setTimeout(r, NLS_FRAME_MS));
+    }
+
+    const finalText = this.transcript.finalText;
+
+    if (this.socket?.readyState === WebSocket.OPEN) {
+      this.socket.send(buildStopTranscription({ appkey: this.appkey, taskId: this.taskId }));
+      await new Promise<void>((resolve) => {
+        this.stopResolve = resolve;
+        const timer = window.setTimeout(() => {
+          this.stopResolve = null;
+          resolve();
+        }, STOP_TIMEOUT_MS);
+        const original = this.stopResolve;
+        this.stopResolve = () => {
+          window.clearTimeout(timer);
+          original?.();
+        };
+      });
+    }
+
+    return { text: this.transcript.finalText || finalText, command: "end" };
+  }
+
+  /** 停采集、收音频、断连接(壳组件在 `transform()` 之后调用) */
+  close(): void {
+    this.processing = false;
+    this.stopping = false;
+    this.pendingAudio = [];
+    this.stopSendTimer();
+    if (this.stream) {
+      this.stream.getTracks().forEach((track) => track.stop());
+    }
+    this.closeSocket();
+  }
+
+  /** 清干净(断开音频节点、清事件监听)——调用后实例作废,调用方置 null */
+  clear(): void {
+    this.source?.disconnect();
+    this.processor?.disconnect();
+    this.audioContext?.close?.().catch(() => {
+      /* 关闭失败无所谓:上下文本来就随页面回收 */
+    });
+    this.audioContext = null;
+    this.source = null;
+    this.processor = null;
+    this.stream = null;
+    this.socket = null;
+    this.pendingAudio = [];
+    this.allClear();
+  }
+
+  private closeSocket(): void {
+    const socket = this.socket;
+    this.socket = null;
+    if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) {
+      try {
+        socket.close();
+      } catch {
+        /* ignore */
+      }
+    }
+  }
+
+  private stopSendTimer(): void {
+    if (this.sendTimer !== null) {
+      window.clearInterval(this.sendTimer);
+      this.sendTimer = null;
+    }
+  }
+
+  /** 按实时节奏把缓存里的音频推给网关(每 `NLS_FRAME_MS` 推一帧的量) */
+  private startFlushTimer(): void {
+    this.stopSendTimer();
+    const bytesPerFrame = (NLS_SAMPLE_RATE * 2 * NLS_FRAME_MS) / 1000; // 3200 字节
+    let carry = new Uint8Array(0);
+
+    this.sendTimer = window.setInterval(() => {
+      if (!this.processing && this.pendingAudio.length === 0) return;
+      if (this.socket?.readyState !== WebSocket.OPEN) return;
+
+      while (carry.length + (this.pendingAudio[0]?.length ?? 0) < bytesPerFrame && this.pendingAudio.length) {
+        const next = this.pendingAudio.shift() as Uint8Array;
+        const merged = new Uint8Array(carry.length + next.length);
+        merged.set(carry, 0);
+        merged.set(next, carry.length);
+        carry = merged;
+      }
+
+      while (carry.length >= bytesPerFrame) {
+        if (this.socket?.readyState !== WebSocket.OPEN) return;
+        this.socket.send(carry.subarray(0, bytesPerFrame));
+        carry = carry.subarray(bytesPerFrame);
+      }
+    }, NLS_FRAME_MS);
+  }
+}

+ 16 - 0
src/utils/runtime-config.ts

@@ -36,6 +36,22 @@ export function getDmsApiBaseUrl() {
   return value.trim().replace(/\/+$/, "");
 }
 
+/**
+ * NLS(阿里云智能语音交互)token 端点。
+ *
+ * 前端**不持有**任何阿里云凭据:token 由服务端用 AccessKeySecret 签名换取后下发
+ * (开发环境是 `vite.config.ts` 的 `/nls-token/` 中间件,见 `harness/tools/nls-token.mjs`;
+ * 生产要由后端/网关提供同形态的端点 —— 技术债 #10)。
+ *
+ * 同源相对路径优先(`/nls-token/`),也允许用 env / index.html 注入覆盖。
+ */
+export function getNlsTokenApiBaseUrl() {
+  const bootstrap = ((globalThis as any).VITE_NLS_TOKEN_API || "") as string;
+  const fromEnv = (import.meta.env.VITE_NLS_TOKEN_API || "") as string;
+  const value = bootstrap && bootstrap !== "%VITE_NLS_TOKEN_API%" ? bootstrap : fromEnv;
+  return (value || "/nls-token").trim().replace(/\/+$/, "");
+}
+
 /**
  * 取本机 IP 的接口前缀(见 `src/utils/client-ip.ts`)。
  *