asr-realtime.md 17 KB

实时语音转文字(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
事件基类(mitt 封装) 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. 构造与参数

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 —— 文本增量(唯一的文本出口)

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

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()(线性插值重采样) → to16BitPCM()(钳到 ±1 后转 int16 小端)→ 推入 audioData。
  3. 定时器每 80ms 检查一次:非讯飞分支攒够 5120 字节(= 2560 采样 = 160ms 音频)才发一帧, 作为二进制帧发出。也就是说端到端延迟由"满 160ms"决定,不是每 80ms 都发。
    • 5120 = 1280 × 4;讯飞分支的发帧间隔是 40ms、块大小 1280 字节。

7. 两种用法(SDK 都支持,当前只用第一种)

7.1 实时流式(当前在用)—— 边说边出字

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() 里那段:

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 已存在但没人用),去掉两处硬编码。