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