ソースを参照

docs(harness): 补一份实时语音转文字(ASR)接口文档

用户要求:「将当前实时语音转文字的功能整理成一份 API 文档放在 harness 文件夹里面」。

新增 harness/docs/reference/asr-realtime.md(275 行),并在 reference/README.md 索引表与
architecture.md 的 ASR 段落加上指路。内容全部逐条对源码核对(SDK 就在仓库里:
src/three-libs/asr/index.ts,578 行),不引厂商文档、不凭记忆:
- 能力边界、代码位置、构造参数(appKey / MediaStreamConstraints / asrServerOptions 及默认值)
- 方法表(openMicroPhone / startRecord / openSocket / transform / close / clear + 事件 API)
- 事件契约:transform 的 {type:current:transformText:command}(累计值,直接覆盖输入框)、error
- **底层 WebSocket 协议**:URL 与 query(project=qingpu&engine=aliyun_dashscope、
  is_long_connection、heartbeat、移动端 max_sentence_silence=5000)、首帧(当前分支实际发的是 {})、
  二进制音频帧(1280×4 字节/80ms)、结束帧、服务端 {command,text,is_sentence,is_quit} → mid/fin/breath 映射
- 音频编码:16kHz/16bit/单声道、线性插值重采样、ScriptProcessorNode(已废弃 API)
- 两种用法(实时流当前在用;整段录音 SDK 支持但未用)、env 现状表、两端生命周期对照、手动自查清单

整理过程中核实出 6 条已知坑,都写进文档 §9(**均未改代码**):
1. to16kHZ 把输入当 44100 算,而 AudioContext 实际常是 48000 → 送出的「16k」比真实语速慢约 8.8%
2. 「麦克风权限被拒绝」的专用文案永远不会出现(它是在 permissions.query().then() 里 throw 的,
   不会让 openMicroPhone reject)→ 用户只看到通用的「获取麦克风流失败…」
3. openMicroPhone 只会 throw 或返回 true → 两端 if (!success) 分支(含 PC 那个静默 return)
   都是不可达代码(顺带修正上午报给用户的「PC 静默分支」:不是活缺口,是死代码)
4. 非讯飞分支不主动 socket.close()(clear() 里那行有 isXunFei &&),依赖服务端自己关
5. 硬编码了一个有效期到 2034-12-29 的 JWT,而当前分支根本不使用它(既是死代码又是风险面)
6. clear() → allClear() 会清空所有事件监听,停止再开必须重新 addEventListener(两端都符合)

验证:validate-harness 通过;文档类改动按 sops/verification.md 只需构建通过,且本次**没碰任何代码**,
所以额外做的是逐条回源码核对(构造/方法/事件/URL 参数/帧格式/事件映射 + 两端调用点差异:
is-voice-input-starting 取值、实例复用 vs 新建、onBeforeUnmount 的差异)。
⚠️ 服务端侧行为(asr_hub 的 project/engine 是否仍有效)**未实测** —— 只能打真实服务验,本次没做;
harness 里也没有 ASR 自动化脚本(依赖麦克风与厂商服务),文档给的是手动自查清单。

Co-Authored-By: Claude Code <noreply@anthropic.com>
gongtianxiao 1 ヶ月 前
親
コミット
25eafe216c

+ 8 - 0
README.md

@@ -413,6 +413,14 @@
   - 读侧不受影响:远端会话列表本来就**排除 `c_is_login="0"` 的访客行**,且只在登录后拉取
   - 验证:`verify-dms-chat-storage.mjs` 78 → **89/89**(打真实 DMS 新增 11 项,含"后续轮次带回
     企业信息时这个纯访客行能正常被填上");变异测试注入旧行为 → 断言大面积失败
+- **实时语音转文字补了一份接口文档**(用户 2026-09-23 要求):
+  [`harness/docs/reference/asr-realtime.md`](harness/docs/reference/asr-realtime.md) ——
+  SDK 在仓库里(`src/three-libs/asr/index.ts`),文档逐条对源码核对:构造参数与方法、
+  `transform`/`error` 事件契约、**底层 WebSocket 协议**(`/common/asr_hub?project=qingpu&engine=aliyun_dashscope…`、
+  首帧、16k/16bit 单声道 PCM 的二进制帧、结束帧、服务端 `start`/`stop` → mid/fin/breath 的映射)、
+  两端的生命周期与调用点、以及**6 条已知坑**(采样率换算写死 44100、"权限被拒绝"文案不可达、
+  `openMicroPhone` 只会 throw 或返回 true、非讯飞分支不主动关 socket、硬编码的十年期 JWT、
+  `clear()` 会清空所有监听)。**只是文档,没改任何代码。**
 - **🔴 待处理(登记进技术债 #9)**:`.env.production` / `.env.qingpu` / `.env.test`
   **都没有配 `VITE_DMS_API`** —— 这些环境打出来的包里附件**传不上去**(直接抛「未配置 DMS 接口地址」)、
   DMS 会话落库也**静默跳过**。接生产要先定:DMS 从部署环境是否可达、**token 由谁注入**

+ 1 - 0
harness/docs/architecture.md

@@ -18,6 +18,7 @@
 - **没有 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)。
   > 注:本文曾写「`src/three-libs/metamaker` 是未被引用的 SDK 包」——**那个目录不存在**,
   > 2026-09-18 核查时已更正。目录名 `three-libs` 有误导性:它与 three.js 无关。
 - **没有状态管理库**(无 Vuex / Pinia)。对话状态在 `useBusinessAssistantChat` 组合式函数内,

+ 4 - 1
harness/docs/reference/README.md

@@ -17,6 +17,7 @@
 | `DMS_COLUMNS.md` | 📄 **栏目现状** | 查 DMS 五个栏目的 columnId / 字段时 |
 | `DMS_MAPPING.md` | 📄 **表→栏目映射** | 做数据迁移、核对字段映射时 |
 | `current-api-call-sites.md` | 📋 **现状清单** | 想知道「还有哪些功能在用老接口 / 哪些是死代码」时 |
+| `asr-realtime.md` | ✅ **现状记录 + 契约** | 改输入框语音(麦克风)相关代码之前 —— 含 SDK 的 WebSocket 协议、事件契约与 6 条已知坑 |
 
 > **DMS 三份文档的来路**:它们出自**另一个项目**
 > `F:\yysk\AI_zhaoshang\DMS_Data_Migration\`(那边有 `harness/artifacts/dms_openapi.json`、
@@ -27,7 +28,9 @@
 > **栏目现状**(2026-09-18 更新,原「五个栏目全空」的说法已过期):
 > - **1887 助手会话 / 1889 助手问答记录**:前端已接入(会话、整段对话、反馈都写这里),
 >   属于**在用的在线数据源**。见 [`../exec-plans/active/dms-chat-storage.md`](../exec-plans/active/dms-chat-storage.md)
->   (注:1887 的写入已于 2026-09-18 按用户指示**停用**,只保留读取与删除清理)
+>   (注:1887 的写入**仍在**——"新建会话/改名不再同步"是 2026-09-18 的决定,
+>   而"回答完成时 upsert 一次、只要对话就建行"是 2026-09-22 / 09-23 的现行口径,
+>   见 [`current-api-call-sites.md`](current-api-call-sites.md) 顶部那段)
 > - **1886 企业荣誉信息 / 1888 企业基础信息**:**两栏目都已有数据**(2026-09-17 18:12 由
 >   DMS 侧 `ams_user` 灌入,不再是空的——此前文档里「仍是空的」已过期)。
 >   1888 的行是完整工商信息(企业名/状态/地址/经营范围等),**没有** `c_tag_*`;

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

@@ -0,0 +1,275 @@
+# 实时语音转文字(ASR)—— 前端接入文档
+
+> **整理时间**:2026-09-23 | **依据**:仓库内的实现源码(不是厂商文档,也不是记忆)
+> **性质**:**现状记录 + 接口契约**。厂商 SDK 是本地源码(`src/three-libs/asr/index.ts`,578 行),
+> 所以这里写的每一条都能在代码里指到行;厂商侧若改了服务行为,**以实际观测为准**,
+> 并回头更新本文档。
+>
+> **本文档不是改动许可**:语音相关代码目前只在「输入框的麦克风按钮」这一处使用,
+> 改它之前先读 §9 的已知行为与坑。
+
+## 1. 这套东西是干什么的
+
+把用户**边说边出的字**送进输入框:浏览器采集麦克风 → 前端做重采样与 PCM 编码 →
+**WebSocket 流式**送音频帧 → 服务端回增量文本 → 前端把**累计文本**写进 `inputText`。
+用户再点一次麦克风(或直接点发送)结束,最终文本留在输入框里,走原来的文字提问链路。
+
+**一句话契约**:ASR 只负责"产出文本",不参与提问。文本落到 `inputText` 之后,
+后面的发送、附件、DMS 落库与手打文字完全同路。
+
+## 2. 代码位置一览
+
+| 是什么 | 在哪 |
+|---|---|
+| SDK(全部实现,含协议细节) | [`src/three-libs/asr/index.ts`](../../../src/three-libs/asr/index.ts) |
+| 事件基类(mitt 封装) | [`src/utils/base-event.ts`](../../../src/utils/base-event.ts) |
+| 移动端调用点 | `BusinessAssistantMobile.vue` 的 `toggleVoiceInput` / `stopVoiceInput` / `releaseVoiceInputInstance` |
+| 桌面端调用点 | `BusinessAssistantPC.vue` 的同名三函数 + `drawWave`(波形) |
+| 输入框 UI(麦克风按钮) | `BusinessAssistantMobileInputShell.vue` / `BusinessAssistantComposer.vue` → 由两个壳组件传 props、接 `voiceToggle` 事件 |
+| 环境变量 | `VITE_ASR`(真实地址)、`VITE_XF_ASR`(讯飞分支前缀)、`VITE_APP_KEY`(**当前未使用**,见 §8) |
+| 开发代理 | `vite.config.ts` 的 `/asr/` → `https://human-screen-v3.metamaker.cn`,**`ws: true`** |
+
+## 3. 构造与参数
+
+```ts
+import ASR from '@/three-libs/asr/index';
+
+const asr = new ASR(appKey, CONSTANTS?, asrServerOptions?);
+```
+
+| 参数 | 类型 | 当前调用方传的值 | 说明 |
+|---|---|---|---|
+| `appKey` | `string` | `"ce6cb2020037ed20f8a09a4822e869f380a0de96"`(**硬编码在两个壳组件里**) | 只在讯飞分支用来换 token;当前分支**用不到** |
+| `CONSTANTS` | `MediaStreamConstraints` | `{ audio: true, video: false }` | 直接交给 `getUserMedia`。SDK 自带的默认值是 `{audio:{echoCancellation:false,noiseSuppression:false,autoGainControl:false}}`(**没有生效**,因为调用方显式传了) |
+| `asrServerOptions` | 见下表 | **没传**(用默认值) | ⚠️ 这些参数**只在讯飞分支发出去**,当前分支实际不发 —— 见 §6.2 |
+
+`asrServerOptions` 默认值:
+
+| 键 | 默认 | 含义(SDK 头部注释) |
+|---|---|---|
+| `language` | `"zh-cn"` | 语种 |
+| `domain` | `"iat"` | 应用领域(日常用语) |
+| `accent` | `"mandarin"` | 方言/口音 |
+| `vad_eos` | `10000` | 端点检测静默时间(毫秒) |
+| `dwa` | `"wpgs"` | 流式结果动态修正(**当前分支不生效**) |
+
+## 4. 方法
+
+| 方法 | 签名 | 作用 | 注意事项 |
+|---|---|---|---|
+| `openMicroPhone()` | `() => Promise<boolean>` | 申请麦克风,存下 `stream` | **只会 `throw` 或返回 `true`**(见 §9.2):失败时抛 `Error("获取麦克风流失败,请检查麦克风是否正常工作")` |
+| `startRecord()` | `() => void` | 建 `AudioContext` + `ScriptProcessorNode`,开始把音频累积到 `audioData` | **必须在 `openSocket()` 之前调用**:`openSocket` 的 `onopen` 里若发现 `processing === false` 会**直接 close 掉连接** |
+| `openSocket()` | `() => Promise<void>` | 换 token、拼 URL、建 WebSocket、首帧、并启动定时发音频 | 失败会 `reject`(调用方 catch 后 toast);`ws.onerror` 同时会 emit `error` 事件 |
+| `transform()` | `() => Promise<{text, command}>` | **结束一轮**:停麦克风轨道 → 把剩余音频发完 → 发结束帧 → 等服务端最后一段文本 → resolve 最终文本 | 当前两端都在"停止录音"时 `await` 它,用返回值覆盖 `inputText` |
+| `close()` | `() => void` | 清空待发音频、停定时器、发结束帧、停麦克风轨道 | 见 §9.3:当前分支**不会真的 `socket.close()`** |
+| `clear()` | `() => void` | 断开音频节点、清定时器、`_init()`(丢引用)、清空**所有事件监听** | 调完这个实例基本作废;调用方随后把它置 `null` |
+| `addEventListener(name, cb)` | 继承自 `BaseEvent`(mitt) | 注册事件,返回 `this` | 事件名见 §5,只有 `transform` 与 `error` 两个 |
+| `removeEventListener(name, cb?)` | 同上 | 注销 | |
+| `allClear()` | 同上 | 清掉全部监听 | `clear()` 内部会调它 |
+
+## 5. 事件契约
+
+### `transform` —— 文本增量(**唯一的文本出口**)
+
+```ts
+asr.addEventListener('transform', (arg: {
+  type: 'mid' | 'fin' | 'breath';   // 中间结果 / 一句定稿 / 断句点
+  current: string;                  // 本次这一小段文本
+  transformText: string;            // 【累计】文本 —— 直接用它覆盖输入框
+  command: 'quit' | '';
+}) => { inputText.value = arg.transformText; });
+```
+
+- `transformText` 是**累计值**(不是增量),所以调用方是**赋值**不是追加。
+- 三种 `type` 的差别只在语义:`mid` 会被后续结果修正/替换,`fin` 是定稿的一句,
+  `breath` 表示服务端按标点断了一句。当前调用方**不区分**,一律用 `transformText` 覆盖。
+
+### `error`
+
+```ts
+asr.addEventListener('error', (payload) => { /* {message} 或 WebSocket 的 error 事件 */ });
+```
+
+- 触发点有两处:`startRecord()` 时没有 `stream`(`{message:'未获取到麦克风流'}`)、
+  以及 `ws.onerror`(把原生事件对象原样抛出)。
+- ⚠️ **当前两个壳组件都没有注册 `error`**:WebSocket 出错时它们靠的是 `openSocket()` 的
+  reject(await 处 catch → toast),所以运行中掉线的提示会比较"哑"。
+
+## 6. 底层协议(WebSocket)
+
+### 6.1 连接地址
+
+```
+协议:页面是 https 就用 wss,否则 ws
+主机:取自 VITE_ASR 的 host(当前 = qingpu-data-api.metamaker.cn)——
+      所以语音是**直连厂商域名**的,vite 的 /asr 代理只服务讯飞分支
+```
+
+**当前分支(非讯飞)**:
+
+```
+{wss|ws}://{VITE_ASR 的 host}/common/asr_hub
+  ?project=qingpu
+  &engine=aliyun_dashscope
+  &is_long_connection=true
+  &heartbeat=true
+  [&max_sentence_silence=5000]   ← 仅当 UA 含 Mobile / MicroMessenger(isMobile())
+```
+
+`project` 与 `engine` 是**硬编码**在 SDK 里的(`qingpu` / `aliyun_dashscope`)。
+
+**讯飞分支(保留、当前关闭)**:`{协议}://{VITE_XF_ASR 的 host}/api/human_asr/v2/asr?Authorization={token}`,
+token 由 `POST {VITE_XF_ASR}/api/human_asr/v2/asr/gen_auth {app_key}` 换取。
+开关是 **`globalThis.isXF`**(在 `index.html` 里硬编码为 `false`,没有 env 入口)。
+
+### 6.2 上下行帧
+
+| 方向 | 内容 | 当前分支 |
+|---|---|---|
+| 客户端 → 首帧 | 讯飞:`{business:{language,domain,accent,vad_eos,dwa}, data:{status:0,format:'audio/L16;rate=16000',encoding:'raw',audio:''}}` | ✗ 不发 |
+| 客户端 → 首帧 | 非讯飞:`{card_id: asrServerOptions.card_id}` —— **调用方没传 card_id,序列化后就是 `{}`** | ✓ 实际发出的就是 `{}` |
+| 客户端 → 音频 | **二进制帧**:16kHz / 16bit / 单声道 PCM 的原始字节 | ✓ |
+| 客户端 → 结束 | 讯飞 `{data:{status:2}}`;非讯飞 `{"command":"close"}` | ✓ `{"command":"close"}` |
+| 服务端 → 文本 | 非讯飞:`{command:'start'\|'stop', text, is_quit, is_sentence}` | ✓ |
+
+**服务端下行 → 事件的映射**(SDK 内固定):
+
+| 服务端 | 触发的事件 |
+|---|---|
+| `command === 'start'` | `transform` type=`mid`(`transformText` 被**整段替换**为 `text`) |
+| `command === 'stop'` | `transform` type=`fin`(同上,整段替换);同时记下"已定稿长度" |
+| `text` 以标点结尾 且 `is_sentence` | `transform` type=`breath`(当前分支同样是**整段替换**;`transformTextFix` 不变) |
+
+> 标点集合是硬编码的一串中英文标点(`。,!,?;:、”’)》】`),**不包含英文句点 `.`**。
+
+### 6.3 音频编码与发送节奏
+
+1. `getUserMedia` 拿到麦克风流 → `MediaStreamAudioSourceNode` → `ScriptProcessorNode`
+   (`createScriptProcessor(0, 1, 1)`,bufferSize 传 0 让浏览器自选;**该 API 已废弃**)。
+2. 每个回调把 `Float32` 采样 → [`to16kHZ()`](../../../src/three-libs/asr/index.ts)(线性插值重采样)
+   → [`to16BitPCM()`](../../../src/three-libs/asr/index.ts)(钳到 ±1 后转 int16 小端)→ 推入 `audioData`。
+3. 定时器每 **80ms** 检查一次:非讯飞分支**攒够 5120 字节(= 2560 采样 = 160ms 音频)才发一帧**,
+   作为二进制帧发出。也就是说端到端延迟由"满 160ms"决定,不是每 80ms 都发。
+   - 5120 = `1280 × 4`;讯飞分支的发帧间隔是 40ms、块大小 1280 字节。
+
+## 7. 两种用法(SDK 都支持,当前只用第一种)
+
+### 7.1 实时流式(**当前在用**)—— 边说边出字
+
+```ts
+const asr = new ASR(appKey, { audio: true, video: false });
+await asr.openMicroPhone();
+asr.addEventListener('transform', (a) => { inputText.value = a.transformText; });
+asr.startRecord();
+await asr.openSocket();          // ← 顺序不能反
+// …用户说话,输入框持续更新…
+const { text } = await asr.transform();   // 停止:拿最终文本
+asr.close();
+asr.clear();
+```
+
+### 7.2 整段录音(SDK 支持,**当前未使用**)
+
+`openMicroPhone()` → `openSocket()` → (录)→ `await asr.transform()` 一次性拿整段文本。
+SDK 注释说这是"手机长按说话"的形态。当前两端都没走这条路。
+
+## 8. 环境变量现状(2026-09-23)
+
+| 变量 | 开发 | 生产 | 青浦 | 测试 | 用在哪 |
+|---|---|---|---|---|---|
+| `VITE_ASR` | `https://qingpu-data-api.metamaker.cn` | `wss://qingpu-data-api.metamaker.cn` | `wss://…` | `https://…` | **当前分支的 socket host**(只取 host,协议按页面推) |
+| `VITE_XF_ASR` | `/asr` | (无) | `/asr` | (无) | 讯飞分支:既做 gen_auth 前缀,也做 socket host(走 vite 代理) |
+| `VITE_APP_KEY` | 有值 | 有值 | 有值 | 有值 | ⚠️ **`src/` 里没有任何引用** —— 死配置;appKey 是硬编码的 |
+
+> `VITE_ASR` 写成 `https://…` 或 `wss://…` 都能用(SDK 只取 `.host`);写成 `/asr` 这种相对路径则
+> host 取当前页面域名 —— 这条早先的写法在 `.env.qingpu` 里以注释形式留着一行(`# VITE_ASR="/asr"`)。
+
+## 9. 已知行为与坑(**改之前先读**)
+
+### 9.1 采样率换算写死 44100
+
+`to16kHZ()` 里是 `Math.round(data.length * (16000 / 44100))`,而 `AudioContext` 的采样率是
+**设备/浏览器给的**(Windows 与多数安卓常见 48000,macOS 常见 44100)。
+实际是 48000 时,这段换算产出的样本数比真正的 16k 多约 8.8%,而服务端仍按 `rate=16000` 读
+→ **送出去的音频比真实语速慢约 8%**,可能影响识别率。当前没有任何地方纠正它。
+
+### 9.2 "权限被拒绝"的专用文案**永远不会出现**
+
+`openMicroPhone()` 里那段:
+
+```ts
+navigator.permissions.query({name:'microphone'}).then((s) => {
+  if (s.state === 'denied') throw new Error('麦克风权限被拒绝,请在系统设置或浏览器设置中启用!');
+});
+```
+
+是在 `.then()` 里抛的 —— **不会**让 `openMicroPhone()` reject,只会变成一个 unhandled rejection。
+用户实际看到的是 `getUserMedia` 失败后那条通用文案「获取麦克风流失败,请检查麦克风是否正常工作」。
+
+### 9.3 `openMicroPhone()` 只会 throw 或返回 `true`
+
+两个壳里的 `if (!success) …` 分支(PC 的静默 `return`、Mobile 的"未获取到麦克风权限" toast)
+**都是到不了的分支**(`return false` 只在 `throw` 之后,不可达)。真要区分"没授权",
+得改 `openMicroPhone` 的失败语义(或读 `getUserMedia` 抛的 `NotAllowedError`)。
+
+### 9.4 非讯飞分支不会主动 `socket.close()`
+
+`clear()` 里关连接那行有 `isXunFei &&` 前置条件;`close()` 只是发一个 `{"command":"close"}` 帧。
+所以当前分支**依赖服务端在收到结束帧后自己关连接**,前端只是丢掉引用。
+(排查"连接泄漏/后端连接数"时先想到这一条。)
+
+### 9.5 硬编码的长期 token(安全项)
+
+`fetchToken()` 的非讯飞分支里写死了一个 JWT(`iss: human-large-screen`,`userid 91`,
+**有效期到 2034-12-29**),而且**当前分支根本不用它**(只有讯飞分支的 URL 会带 Authorization)。
+即:仓库里躺着一个十年有效期的凭据,既是死代码又是风险面。
+
+### 9.6 事件监听会被 `clear()` 清空
+
+`clear()` → `allClear()` 清掉**所有** `transform`/`error` 监听。所以"停止 → 再开"必须重新
+`addEventListener`(两个壳都是每次 `toggleVoiceInput` 里重新注册,符合这个约束)。
+
+### 9.7 其它小事实
+
+- Mobile 的麦克风按钮在启动期间 disabled(`isVoiceInputStarting`);**PC 那边把
+  `is-voice-input-starting` 恒传 `false`**,所以 PC 启动瞬间连点可能触发两次初始化。
+- `openSocket()` 的 `onopen` 里若 `processing === false` 会直接 `close()` —— 这就是
+  「先 `startRecord()` 再 `openSocket()`」这条顺序约束的来源。
+- 语音**不会**自动发送:停止录音只把文本放进输入框,发送仍由用户点按钮触发。
+- 输入框里已有文字时点麦克风,会先弹 `window.confirm("可能会清空之前的文字,确认要清空吗?")`
+  —— 这是**模态确认**,不是轻提示,故意保留(见 progress.md 续 27)。
+
+## 10. 生命周期(当前两端的实际接法)
+
+| 触发 | 移动端 | 桌面端 |
+|---|---|---|
+| 点麦克风(开) | 已有文字 → confirm;新建 ASR;`openMicroPhone` → `addEventListener('transform')` → `startRecord` → `openSocket` | 同左(但**复用**已存在的实例,不新建)+ 额外接一个 `AnalyserNode` 画波形 |
+| 点麦克风(关) | `stopVoiceInput()`:`await transform()` 取最终文本 → 覆盖输入框 → `releaseVoiceInputInstance()`(close+clear+置 null) | 同左,并停掉波形动画 |
+| **发送前** | `beforeSend` 里若在录音 → 先 `stopVoiceInput()`(避免麦克风占着) | 同左 |
+| 初始化失败 | toast(`showToast`)+ 释放实例 | 同左 + `stopVoiceInput()` 收尾 |
+| 组件卸载 | `onBeforeUnmount` 无条件 `void stopVoiceInput()` | `onBeforeUnmount` 里**仅当**在录音时停 |
+
+## 11. 怎么自查(改完/排查时的手动步骤)
+
+1. `npm run dev` → 打开 https://localhost:8083(自签证书告警正常),**允许**麦克风。
+2. 点麦克风按钮 → 说一句"青浦区高新技术企业奖励" → 输入框应**边说边出字**(累计、会被修正)。
+3. 桌面端同时应看到输入框里的波形在动。
+4. 再点一次麦克风 → 文本保留、录音停止;(桌面端)波形停止。
+5. 拒绝麦克风权限重试 → 应弹顶部轻提示「获取麦克风流失败,请检查麦克风是否正常工作」。
+6. 查连接:DevTools → Network → WS,应看到到 `wss://qingpu-data-api.metamaker.cn/common/asr_hub?...`
+   的帧:首帧文本 `{}`、随后二进制帧(约 5120 字节/帧)、结束时 `{"command":"close"}`。
+
+> harness 里**没有** ASR 的自动化验证脚本:它依赖麦克风与厂商服务,无法在 node 里稳定复现。
+> 上面这条手动清单就是当前的验证口径;改动后请在 progress.md 里写明**实际做过的步骤**。
+
+## 12. 未决 / 可以顺手做的
+
+按"是否值得动"排序,**都没做**(用户 2026-09-23 只要求整理文档):
+
+1. **采样率**:`new AudioContext()` 时显式传 `sampleRate: 16000`(或用 `audioContext.sampleRate` 参与换算)
+   —— 成本很低,收益是识别质量。
+2. **权限失败文案**:把 `permissions.query` 那段挪出 `.then()`(或捕获 `NotAllowedError`),
+   让用户看到"没授权"而不是通用的"检查麦克风"。
+3. **硬编码 token**:确认非讯飞分支确实不需要它后删除;需要就改由服务端下发。
+4. **`error` 事件没人监听**:运行中掉线时给用户一条提示(现在只有初始化阶段会 toast)。
+5. **`appKey` 从 env 读**(`VITE_APP_KEY` 已存在但没人用),去掉两处硬编码。

+ 40 - 0
harness/progress.md

@@ -3983,4 +3983,44 @@ payload 里含模型里**不存在**的列时,`addContent` 会忽略该列照
 **⚠️ 仍未做(等用户决定)**:中断/报错的轮次(停止生成、409、断流)**仍不会**在 1887 建行
 —— 这是用户选定的「建行时机保持现状」,不是漏做。
 
+### 续 31:把实时语音转文字整理成接口文档(用户要求)
+
+**用户要求**:「将当前实时语音转文字的功能整理成一份 API 文档放在 harness 文件夹里面」。
+
+**产出**:新建 [`harness/docs/reference/asr-realtime.md`](docs/reference/asr-realtime.md)(275 行),
+并在 `docs/reference/README.md` 的索引表与 `docs/architecture.md` 的 ASR 那段加上指路。
+**内容全部来自源码核对**(不引厂商文档、不凭记忆):SDK 就在仓库里
+(`src/three-libs/asr/index.ts`,578 行),所以每条都能指到行。
+
+**文档覆盖**:能力边界(只产文本、不参与提问)|代码位置一览|构造参数(appKey / MediaStreamConstraints /
+asrServerOptions 及其默认值)|方法表(`openMicroPhone` / `startRecord` / `openSocket` / `transform` /
+`close` / `clear` + 事件 API)|事件契约(`transform` 的 `{type, current, transformText, command}`、
+`error`)|**底层 WebSocket 协议**(URL 拼法与 query 参数、首帧、二进制音频帧、结束帧、
+服务端 `{command,text,is_sentence,is_quit}` → mid/fin/breath 的映射表)|音频编码与发送节奏
+(16k/16bit/mono、线性插值重采样、1280×4 字节/80ms 一轮)|两种用法(实时流 vs 整段录音,
+后者当前未用)|env 现状表|**6 条已知坑**|两端生命周期对照表|手动自查清单。
+
+**整理过程中核实出的 6 条「已知坑」(都写进文档 §9,都未改代码)**:
+1. **采样率换算写死 44100**(`to16kHZ`),`AudioContext` 实际常是 48000 → 送出的"16k"比真实语速慢约 8.8%
+2. **"麦克风权限被拒绝"的专用文案永远不会出现** —— 它在 `permissions.query().then()` 里 `throw`,
+   不会让 `openMicroPhone()` reject;用户看到的是通用的「获取麦克风流失败…」
+3. **`openMicroPhone()` 只会 throw 或返回 `true`** → 两端 `if (!success)` 分支(含 PC 那个静默 return)
+   **都是不可达代码** ← 顺带修正了我 09-23 上午报给用户的"PC 静默分支"那一条:它不是活缺口,是死代码
+4. **非讯飞分支不主动 `socket.close()`**(`clear()` 里关连接那行有 `isXunFei &&`),依赖服务端自己关
+5. **硬编码了一个有效期到 2034-12-29 的 JWT**(`iss: human-large-screen`),而当前分支根本不使用它
+   —— 既是死代码又是风险面
+6. **`clear()` 会清空所有事件监听**(`allClear`)→ 停止再开必须重新 `addEventListener`(两端都符合)
+
+**运行过的验证**:`node harness/tools/validate-harness.mjs` 通过(功能 23 项,in_progress 1 项)。
+文档类改动按 `sops/verification.md` 只需要构建通过 —— 但本文档**没有碰任何代码**,
+所以额外做的是**逐条回源码核对**:构造/方法/事件/URL 参数/帧格式/事件映射/两端调用点
+(`is-voice-input-starting` 的取值、实例是复用还是新建、`onBeforeUnmount` 的差异)都对着
+`src/three-libs/asr/index.ts` 与两个壳组件复核过。
+
+**⚠️ 未验证的部分(文档里已注明)**:服务端侧行为(`asr_hub` 的 `project=qingpu` /
+`engine=aliyun_dashscope` 是否仍有效、`vad_eos` 等参数在非讯飞分支是否真的不需要)——
+这些只能打真实服务测,本次**没有**做(没连麦克风、没连厂商服务)。
+harness 里也**没有** ASR 的自动化脚本(依赖麦克风与厂商服务,node 里无法稳定复现),
+所以文档给的是一条**手动自查清单**。
+