整理时间:2026-09-23 | 依据:仓库内的实现源码(不是厂商文档,也不是记忆) 性质:现状记录 + 接口契约。厂商 SDK 是本地源码(
src/three-libs/asr/index.ts,578 行), 所以这里写的每一条都能在代码里指到行;厂商侧若改了服务行为,以实际观测为准, 并回头更新本文档。本文档不是改动许可:语音相关代码目前只在「输入框的麦克风按钮」这一处使用, 改它之前先读 §9 的已知行为与坑。
把用户边说边出的字送进输入框:浏览器采集麦克风 → 前端做重采样与 PCM 编码 →
WebSocket 流式送音频帧 → 服务端回增量文本 → 前端把累计文本写进 inputText。
用户再点一次麦克风(或直接点发送)结束,最终文本留在输入框里,走原来的文字提问链路。
一句话契约:ASR 只负责"产出文本",不参与提问。文本落到 inputText 之后,
后面的发送、附件、DMS 落库与手打文字完全同路。
| 是什么 | 在哪 |
|---|---|
| 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 |
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" |
流式结果动态修正(当前分支不生效) |
| 方法 | 签名 | 作用 | 注意事项 |
|---|---|---|---|
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() 内部会调它 |
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 覆盖。errorasr.addEventListener('error', (payload) => { /* {message} 或 WebSocket 的 error 事件 */ });
startRecord() 时没有 stream({message:'未获取到麦克风流'})、
以及 ws.onerror(把原生事件对象原样抛出)。error:WebSocket 出错时它们靠的是 openSocket() 的
reject(await 处 catch → toast),所以运行中掉线的提示会比较"哑"。协议:页面是 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 入口)。
| 方向 | 内容 | 当前分支 |
|---|---|---|
| 客户端 → 首帧 | 讯飞:{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 不变) |
标点集合是硬编码的一串中英文标点(
。,!,?;:、”’)》】),不包含英文句点.。
getUserMedia 拿到麦克风流 → MediaStreamAudioSourceNode → ScriptProcessorNode
(createScriptProcessor(0, 1, 1),bufferSize 传 0 让浏览器自选;该 API 已废弃)。Float32 采样 → to16kHZ()(线性插值重采样)
→ to16BitPCM()(钳到 ±1 后转 int16 小端)→ 推入 audioData。1280 × 4;讯飞分支的发帧间隔是 40ms、块大小 1280 字节。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();
openMicroPhone() → openSocket() → (录)→ await asr.transform() 一次性拿整段文本。
SDK 注释说这是"手机长按说话"的形态。当前两端都没走这条路。
| 变量 | 开发 | 生产 | 青浦 | 测试 | 用在哪 |
|---|---|---|---|---|---|
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")。
to16kHZ() 里是 Math.round(data.length * (16000 / 44100)),而 AudioContext 的采样率是
设备/浏览器给的(Windows 与多数安卓常见 48000,macOS 常见 44100)。
实际是 48000 时,这段换算产出的样本数比真正的 16k 多约 8.8%,而服务端仍按 rate=16000 读
→ 送出去的音频比真实语速慢约 8%,可能影响识别率。当前没有任何地方纠正它。
openMicroPhone() 里那段:
navigator.permissions.query({name:'microphone'}).then((s) => {
if (s.state === 'denied') throw new Error('麦克风权限被拒绝,请在系统设置或浏览器设置中启用!');
});
是在 .then() 里抛的 —— 不会让 openMicroPhone() reject,只会变成一个 unhandled rejection。
用户实际看到的是 getUserMedia 失败后那条通用文案「获取麦克风流失败,请检查麦克风是否正常工作」。
openMicroPhone() 只会 throw 或返回 true两个壳里的 if (!success) … 分支(PC 的静默 return、Mobile 的"未获取到麦克风权限" toast)
都是到不了的分支(return false 只在 throw 之后,不可达)。真要区分"没授权",
得改 openMicroPhone 的失败语义(或读 getUserMedia 抛的 NotAllowedError)。
socket.close()clear() 里关连接那行有 isXunFei && 前置条件;close() 只是发一个 {"command":"close"} 帧。
所以当前分支依赖服务端在收到结束帧后自己关连接,前端只是丢掉引用。
(排查"连接泄漏/后端连接数"时先想到这一条。)
fetchToken() 的非讯飞分支里写死了一个 JWT(iss: human-large-screen,userid 91,
有效期到 2034-12-29),而且当前分支根本不用它(只有讯飞分支的 URL 会带 Authorization)。
即:仓库里躺着一个十年有效期的凭据,既是死代码又是风险面。
clear() 清空clear() → allClear() 清掉所有 transform/error 监听。所以"停止 → 再开"必须重新
addEventListener(两个壳都是每次 toggleVoiceInput 里重新注册,符合这个约束)。
isVoiceInputStarting);PC 那边把
is-voice-input-starting 恒传 false,所以 PC 启动瞬间连点可能触发两次初始化。openSocket() 的 onopen 里若 processing === false 会直接 close() —— 这就是
「先 startRecord() 再 openSocket()」这条顺序约束的来源。window.confirm("可能会清空之前的文字,确认要清空吗?")
—— 这是模态确认,不是轻提示,故意保留(见 progress.md 续 27)。| 触发 | 移动端 | 桌面端 |
|---|---|---|
| 点麦克风(开) | 已有文字 → confirm;新建 ASR;openMicroPhone → addEventListener('transform') → startRecord → openSocket |
同左(但复用已存在的实例,不新建)+ 额外接一个 AnalyserNode 画波形 |
| 点麦克风(关) | stopVoiceInput():await transform() 取最终文本 → 覆盖输入框 → releaseVoiceInputInstance()(close+clear+置 null) |
同左,并停掉波形动画 |
| 发送前 | beforeSend 里若在录音 → 先 stopVoiceInput()(避免麦克风占着) |
同左 |
| 初始化失败 | toast(showToast)+ 释放实例 |
同左 + stopVoiceInput() 收尾 |
| 组件卸载 | onBeforeUnmount 无条件 void stopVoiceInput() |
onBeforeUnmount 里仅当在录音时停 |
npm run dev → 打开 https://localhost:8083(自签证书告警正常),**允许**麦克风。wss://qingpu-data-api.metamaker.cn/common/asr_hub?...
的帧:首帧文本 {}、随后二进制帧(约 5120 字节/帧)、结束时 {"command":"close"}。harness 里没有 ASR 的自动化验证脚本:它依赖麦克风与厂商服务,无法在 node 里稳定复现。 上面这条手动清单就是当前的验证口径;改动后请在 progress.md 里写明实际做过的步骤。
按"是否值得动"排序,都没做(用户 2026-09-23 只要求整理文档):
new AudioContext() 时显式传 sampleRate: 16000(或用 audioContext.sampleRate 参与换算)
—— 成本很低,收益是识别质量。permissions.query 那段挪出 .then()(或捕获 NotAllowedError),
让用户看到"没授权"而不是通用的"检查麦克风"。error 事件没人监听:运行中掉线时给用户一条提示(现在只有初始化阶段会 toast)。appKey 从 env 读(VITE_APP_KEY 已存在但没人用),去掉两处硬编码。