# 实时语音转文字(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 行), > 所以这里写的每一条都能在代码里指到行;厂商侧若改了服务行为,**以实际观测为准**, > 并回头更新本文档。 > > **本文档不是改动许可**:语音相关代码目前只在「输入框的麦克风按钮」这一处使用, > 改它之前先读 §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` | 申请麦克风,存下 `stream` | **只会 `throw` 或返回 `true`**(见 §9.2):失败时抛 `Error("获取麦克风流失败,请检查麦克风是否正常工作")` | | `startRecord()` | `() => void` | 建 `AudioContext` + `ScriptProcessorNode`,开始把音频累积到 `audioData` | **必须在 `openSocket()` 之前调用**:`openSocket` 的 `onopen` 里若发现 `processing === false` 会**直接 close 掉连接** | | `openSocket()` | `() => Promise` | 换 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` 已存在但没人用),去掉两处硬编码。