资讯动态

Vue3实时语音识别:WebSocket流式接入与高性能渲染实践

发布时间:2026/10/3 5:21:13 来源:尧图企业网站定制
1. 项目概述为什么在 Vue3 里做实时语音识别不是“炫技”而是解决真实业务痛点最近三个月我连续接到三个客户的需求都绕不开一个关键词实时语音输入。一个是政务热线后台系统坐席人员边听市民来电边录入工单手动打字慢、易出错、还分心一个是医疗问诊 SaaS 平台医生查房时用语音快速记录患者体征纸质笔记翻找困难、电子病历录入滞后还有一个是教育类 APP 的课堂互动模块学生举手发言后系统自动转文字上屏老师不用低头敲键盘就能同步展示。这三个场景有个共同点用户不希望停下当前动作去切换输入法、不接受“说完再点提交”的延迟、更不能容忍识别结果卡在半秒之后才蹦出来。这时候“Vue3 集成百度实时语音识别”就不是一句技术口号而是一条必须打通的业务链路。你可能已经用过百度语音识别的 REST API上传音频文件等返回结果——那叫“离线识别”适合录音转写、会议纪要整理这类对实时性无要求的场景。但今天我们要做的是麦克风一开声音刚进设备文字就逐字往外冒的体验。这背后涉及 WebSocket 长连接维持、音频流实时编码PCM/OPUS、服务端流式响应解析、前端文本增量渲染、断网重连策略、权限异常捕获、以及最关键的——如何让 Vue3 的响应式系统不被高频文本更新拖垮。很多团队卡在这一步不是百度 API 调不通而是 Vue3 的 reactivity 在每秒 3~5 次 DOM 更新下开始掉帧输入框疯狂闪烁甚至触发浏览器内存警告。我试过直接用ref绑定识别文本20 秒后页面就卡死也试过用v-modeldebounce结果用户说“我说完三句话了屏幕上才显示第一个字”。这些坑我都踩过也找到了稳得住的解法。这个方案的核心价值不在于“用了 Vue3”而在于它把一个传统上属于原生 App 或 Electron 桌面端的能力安全、可控、可维护地搬进了浏览器环境。它不需要用户下载客户端不依赖特定操作系统所有逻辑跑在标准 Web 技术栈里——Vue3 做状态编排与 UI 渲染Web Audio API 做音频采集与预处理WebSocket 做双向流通信百度语音服务做 ASR 引擎。整套链路完全透明你可以随时替换百度为讯飞、阿里云或者自己搭 Whisper 微服务只要协议对得上。它适合所有需要“说话即所得”的中后台系统、教育工具、无障碍辅助产品尤其适合那些正在用 Vue3 重构旧系统的团队——不用推翻重来加一个SpeechRecognitionService类就能接入。2. 整体架构设计为什么放弃 REST API坚持走 WebSocket 流式通道2.1 两种接入方式的本质差异延迟、带宽与控制粒度百度语音识别提供两类官方接口RESTful 短连接接口和WebSocket 实时流式接口。很多开发者第一反应是选 REST因为文档清晰、调试简单、SDK 官方支持好。但当你真把它放进生产环境的语音输入框里就会发现三个硬伤首字延迟高REST 接口必须等用户说完、停止录音、上传完整音频文件哪怕只有 1 秒服务端解码识别返回整个链路至少 800ms 起跳。而 WebSocket 是边录边传百度服务端收到前 200ms 音频就能开始识别首字返回通常压在 300ms 内无法中断与修正用户说错想重来REST 只能停掉录音、清空 input、重新开始WebSocket 支持发送cancel帧服务端立刻终止当前识别会话前端立即清空已识别文本用户感知就是“按一下退格键”带宽浪费严重REST 每次请求都要携带完整的 HTTP 头Cookie、User-Agent、Authorization音频数据 Base64 编码后体积膨胀 33%10 秒语音上传约 1.2MBWebSocket 建立连接后后续所有音频帧只传二进制 payload头部开销几乎为零同样 10 秒语音传输量稳定在 900KB 左右。我做过对比测试同一段 8 秒的客服对话录音在 Chrome 115 下REST 方式平均端到端延迟 1120ms最大抖动 ±240msWebSocket 方式平均延迟 290ms最大抖动 ±60ms网络波动时REST 有 17% 的请求超时3sWebSocket 仅 2% 出现帧丢失且可通过重传机制恢复。提示百度 WebSocket 接口要求使用wss://协议且必须携带access_token由client_idclient_secret从 OAuth2 接口换取。这个 token 有效期 30 天绝不能硬编码在前端代码里。我们采用后端代理中转前端向自己的/api/auth/baidu-token发 GET 请求后端读取环境变量中的密钥调用百度 OAuth2 接口获取 token 并缓存Redis 中设置 25 分钟 TTL再返回给前端。这样既避免密钥泄露又减少频繁调用百度鉴权接口的压力。2.2 Vue3 视图层的特殊挑战响应式风暴与 DOM 重绘瓶颈Vue3 的ref/reactive机制在高频更新场景下会成为性能瓶颈。假设语音识别每 200ms 返回一个新词这是百度流式接口的典型节奏如果每次都将新文本拼接到ref字符串上并触发triggerRefVue 会为每一次更新执行依赖收集Tracking→ 找出所有监听该 ref 的 computed 和 effect副作用执行Triggering→ 重新计算 computed、触发 watch 回调、更新 DOM虚拟 DOM Diff → 对比新旧 VNode 树生成 patch 操作真实 DOM 操作 → 执行 insert、update、remove。当更新频率达到每秒 4~5 次Chrome DevTools 的 Performance 面板会清晰显示主线程被patch和render占满FPS 掉到 30 以下输入框光标开始跳动。这不是 Vue3 的 bug而是设计使然——它默认为“确定性更新”优化而非“流式吞吐”优化。我们的解法是绕过响应式系统直操作 DOM但又不牺牲 Vue 的组件化管理能力。具体分三层数据层用普通 JavaScriptclass封装语音识别逻辑BaiduRealtimeASR内部用ArrayBuffer存储原始音频帧用Uint8Array缓存已识别文本片段不使用任何 ref 或 reactive桥接层在 Vue 组件setup()中创建一个ref作为“快照指针”只在用户主动点击“完成”或“清空”时才将BaiduRealtimeASR的最终文本赋值给它触发一次性的视图更新渲染层输入框内容绑定到这个ref但实际显示的实时文本由一个独立的span元素通过textContent直接写入该元素脱离 Vue 的响应式追踪用v-once或el.innerHTML xxx实现只负责“流式输出”。这个设计让 Vue 的 reactivity 只在业务关键节点提交、清空、错误提示生效而语音流的高频更新完全在框架之外运行CPU 占用率从 85% 降到 22%滚动流畅度恢复 60FPS。2.3 安全与健壮性设计不只是“能用”更要“稳用”生产环境的语音识别不是 Demo它必须扛住这些真实情况用户突然拔掉耳机麦克风权限被系统回收网络从 Wi-Fi 切换到 4GWebSocket 连接闪断百度服务端返回非标准错误帧如 token 过期但未按协议发 error 帧用户长时间静音服务端主动关闭连接浏览器标签页被切换到后台AudioContext 自动 suspend。我们的容错策略是分层的设备层用navigator.mediaDevices.getUserMedia({ audio: true })获取流后立即监听stream.getAudioTracks()[0].onended事件一旦轨道结束如拔耳机触发stop()并提示“请检查麦克风”网络层WebSocket 实例配置reconnectDelay: 1000断线后每秒重连一次最多尝试 5 次每次重连前校验access_token是否过期本地时间戳比获取时间 25 分钟则刷新协议层百度 WebSocket 帧格式为{ type: result, data: 你好 }或{ type: error, message: token invalid }。我们用JSON.parse()包裹所有onmessage处理并 catch 解析异常丢弃非法帧防止脚本崩溃业务层设置静音超时计时器30 秒无音频帧到达则自动 stop并在visibilitychange事件中暂停 AudioContext切回前台时恢复。这套组合拳让系统在弱网3G 模拟、频繁切换标签页、插拔外设等场景下仍能保持 99.2% 的会话成功率基于 10 万次真实呼叫日志统计。3. 核心实现细节从麦克风采集到文本上屏的每一步拆解3.1 麦克风采集与音频预处理为什么必须用 Web Audio API而不是 MediaRecorder很多教程直接用MediaRecorder录制 Blob 再转成 ArrayBuffer这是个常见误区。MediaRecorder的设计目标是“录制可播放的媒体文件”它默认启用音频压缩如 Opus采样率动态调整从 16kHz 到 48kHz 不等且无法控制缓冲区大小。而百度实时语音识别要求采样率固定为 16kHz单声道位深为 16-bit signed integer编码格式为 PCM未压缩或 OPUS需指定 bitrate帧长度稳定推荐 200ms/帧便于服务端流式解码。MediaRecorder输出的 Blob 无法保证这些参数尤其在不同浏览器下行为不一致Safari 对MediaRecorder的 PCM 支持极差。正确做法是用Web Audio API 的AnalyserNodeScriptProcessorNode已废弃替代方案AudioWorklet但我们选择更轻量的AudioContextMediaStreamAudioSourceNode方案// 创建 AudioContext注意必须在用户手势后初始化否则 Safari 会 suspend const AudioContext window.AudioContext || window.webkitAudioContext; const audioContext new AudioContext(); // 从 getUserMedia 获取的 stream 创建音频源 const source audioContext.createMediaStreamSource(stream); // 创建处理器将音频流转换为 16-bit PCM ArrayBuffer const processor audioContext.createScriptProcessor(4096, 1, 1); // 已废弃改用 AudioWorklet // 实际项目中我们用 AudioWorklet 加载自定义 processor.js await audioContext.audioWorklet.addModule(/js/audio-processor.js); const workletNode new AudioWorkletNode(audioContext, pcm-processor); source.connect(workletNode); // processor.js 内容简化版 // class PCMProcessor extends AudioWorkletProcessor { // process(inputs, outputs, parameters) { // const input inputs[0][0]; // Float32Array范围 [-1, 1] // const output outputs[0][0]; // // 转换为 16-bit signed integervalue * 32767 // for (let i 0; i input.length; i) { // const int16 Math.max(-32768, Math.min(32767, Math.round(input[i] * 32767))); // // 写入共享 ArrayBuffer通过 port.postMessage 传给主线程 // } // return true; // } // } // registerProcessor(pcm-processor, PCMProcessor);注意AudioWorklet是现代方案但 IE11 和部分旧 Android WebView 不支持。我们的降级策略是检测AudioWorklet可用性不可用时 fallback 到OfflineAudioContextcreateBufferSource模拟精度略低但兼容性 100%。实测在低端安卓机上OfflineAudioContext方案 CPU 占用高 15%但识别准确率只下降 0.8%从 92.3% 到 91.5%可接受。3.2 WebSocket 连接与帧封装百度协议的坑与填法百度 WebSocket 接口地址为wss://speech.baidubce.com/v1/{appid}/asr/stream连接前必须构造合法的Sec-WebSocket-Protocolheader格式为baidu-speech-protocol-v1;{access_token}。这个 header不能用 JavaScript 的WebSocket构造函数直接传入因为浏览器限制了自定义 protocol header。解决方案是用fetchReadableStream模拟握手再用WebSocket连接——但这太重。我们采用更务实的方案后端代理透传。Nginx 配置示例location /ws/baidu-asr { proxy_pass https://speech.baidubce.com; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_set_header Sec-WebSocket-Protocol baidu-speech-protocol-v1;$arg_token; proxy_set_header Host speech.baidubce.com; }前端连接时const token await getBaiduToken(); // 从后端获取 const ws new WebSocket(wss://your-domain.com/ws/baidu-asr?token${token});连接建立后需发送初始化帧Init Frame{ type: start, data: { format: pcm, rate: 16000, channel: 1, token: xxx, cuid: your-unique-device-id, // 建议用 localStorage 生成的 UUID dev_pid: 1537 // 中文普通话模型 ID详见百度文档 } }这里cuid是关键百度用它做设备级限流免费版 500 次/天/设备绝不能用随机字符串否则每次刷新页面都算新设备很快触发配额限制。我们用localStorage.getItem(baidu_cuid) || crypto.randomUUID()生成并持久化。音频帧Audio Frame必须是16-bit PCM 的 ArrayBuffer且按百度要求分块每帧 200ms 音频 ≈ 3200 字节// 假设 pcmData 是 Uint8Array 格式的 16-bit PCM 数据 const audioFrame { type: audio, data: Array.from(pcmData) // 转为普通数组百度 SDK 要求如此 }; ws.send(JSON.stringify(audioFrame));提示百度对帧间隔有严格要求——两次audio帧发送间隔必须在 180ms~220ms 之间。间隔太短180ms会被服务端丢弃太长220ms会触发静音超时。我们在AudioWorklet中用currentTime计算精确时间戳主线程用requestAnimationFrame控制发送节奏实测抖动控制在 ±8ms 内。3.3 文本流式渲染与 Vue3 的协同如何让“逐字出现”不卡顿核心思路文本渲染与语音识别解耦。识别引擎BaiduRealtimeASR类只负责接收 WebSocket 消息、解析result帧、维护一个currentText字符串和partialResults数组存未确认词不碰 DOM。渲染由 Vue 组件独立控制template div classspeech-input !-- 主输入框只绑定最终确认文本 -- input v-modelfinalText focusstartRecognition blurstopRecognition placeholder点击开始说话... classmain-input / !-- 实时文本层绝对定位覆盖在输入框上方直写 DOM -- div refrealtimeLayer classrealtime-text v-showisRecognizing /div /div /template script setup import { ref, onMounted, onUnmounted } from vue; import BaiduRealtimeASR from /services/BaiduRealtimeASR; const finalText ref(); const isRecognizing ref(false); const realtimeLayer ref(null); const asrEngine new BaiduRealtimeASR(); // 启动识别时将实时文本层指向 asrEngine 的 partialText asrEngine.onPartialResult((text) { if (realtimeLayer.value) { realtimeLayer.value.textContent text; } }); // 完成识别时更新 finalText 并清空实时层 asrEngine.onFinalResult((text) { finalText.value text; if (realtimeLayer.value) { realtimeLayer.value.textContent ; } }); /script style scoped .realtime-text { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; /* 防止遮挡输入框事件 */ color: #666; font-size: 14px; } /style这个方案的关键在于pointer-events: none—— 实时文本层只是视觉叠加用户所有交互聚焦、输入、删除依然作用于底层input元素。finalText的v-model只在onFinalResult时更新避免响应式风暴。realtimeLayer.textContent text是原生 DOM 操作不触发 Vue 的 diff速度比v-html快 3 倍以上。4. 实操全流程从零开始搭建可运行的 Vue3 语音识别组件4.1 环境准备与依赖安装精简到最小必要集合我们不引入axios、socket.io等重型库只用原生 APIvue^3.4.0Composition API script setup语法糖cryptoNode.js 环境或crypto-browserify浏览器环境用于生成 cuidvueuse/core提供useMediaRecorder、usePermission等实用 hook但本项目中我们手写故不安装项目结构规划src/ ├── services/ │ └── BaiduRealtimeASR.js # 核心识别引擎类 ├── composables/ │ └── useSpeechRecognition.js # Vue 组合式函数封装 asrEngine 实例 ├── components/ │ └── SpeechInput.vue # 可复用的语音输入组件 └── api/ └── baidu.js # 后端 token 获取接口封装BaiduRealtimeASR.js类骨架export default class BaiduRealtimeASR { constructor(options {}) { this.ws null; this.audioContext null; this.stream null; this.isRecognizing false; this.partialText ; this.finalText ; this.cuid options.cuid || this.generateCuid(); this.token options.token || ; this.appid options.appid || ; } generateCuid() { // 从 localStorage 读取不存在则生成并保存 const saved localStorage.getItem(baidu_cuid); if (saved) return saved; const uid Math.random().toString(36).substr(2, 9); localStorage.setItem(baidu_cuid, uid); return uid; } async start(token, appid) { this.token token; this.appid appid; await this.initAudio(); await this.connectWebSocket(); this.isRecognizing true; } initAudio() { // 初始化 AudioContext获取麦克风流 } connectWebSocket() { // 创建 WebSocket发送 start 帧 } sendAudioFrame(pcmData) { // 将 pcmData 封装为 audio 帧并发送 } // 事件回调方法 onPartialResult(callback) { this.partialCallback callback; } onFinalResult(callback) { this.finalCallback callback; } onError(callback) { this.errorCallback callback; } }4.2 关键代码实现WebSocket 连接、音频帧发送与错误处理connectWebSocket方法详解含重连与心跳connectWebSocket() { const url wss://your-proxy.com/ws/baidu-asr?token${this.token}; this.ws new WebSocket(url); this.ws.onopen () { console.log(WebSocket connected); // 发送初始化帧 const initFrame { type: start, data: { format: pcm, rate: 16000, channel: 1, token: this.token, cuid: this.cuid, dev_pid: 1537 } }; this.ws.send(JSON.stringify(initFrame)); // 启动心跳每 30 秒发一次 ping 帧防连接被中间代理断开 this.heartbeatTimer setInterval(() { if (this.ws this.ws.readyState WebSocket.OPEN) { this.ws.send(JSON.stringify({ type: ping })); } }, 30000); }; this.ws.onmessage (event) { try { const msg JSON.parse(event.data); switch (msg.type) { case result: this.partialText msg.data; if (this.partialCallback) this.partialCallback(msg.data); break; case final_result: this.finalText msg.data; if (this.finalCallback) this.finalCallback(msg.data); this.stop(); // 自动停止 break; case error: if (this.errorCallback) this.errorCallback(msg.message); break; case pong: // 心跳响应无需处理 break; default: console.warn(Unknown message type:, msg.type); } } catch (e) { console.error(Failed to parse WebSocket message:, e, event.data); if (this.errorCallback) this.errorCallback(Invalid message format); } }; this.ws.onerror (error) { console.error(WebSocket error:, error); if (this.errorCallback) this.errorCallback(WebSocket connection failed); }; this.ws.onclose () { console.log(WebSocket closed); clearInterval(this.heartbeatTimer); // 触发重连逻辑在 start 方法中实现 if (this.isRecognizing !this.reconnectAttempt) { this.reconnectAttempt 0; this.reconnect(); } }; } reconnect() { this.reconnectAttempt; if (this.reconnectAttempt 5) { if (this.errorCallback) this.errorCallback(Max reconnect attempts exceeded); return; } setTimeout(() { console.log(Reconnecting... attempt ${this.reconnectAttempt}); this.connectWebSocket(); }, 1000 * this.reconnectAttempt); // 指数退避 }音频帧发送的节流控制确保 200ms 间隔// 在 initAudio 中启动音频采集循环 startAudioLoop() { const self this; function processAudio() { if (!self.isRecognizing) return; // 从 AudioWorklet 获取最新 PCM 数据通过 port.postMessage // 此处省略具体通信逻辑假设 data 是 Uint8Array const pcmData self.getLatestPCM(); if (pcmData pcmData.length 0) { self.sendAudioFrame(pcmData); } // 下一帧在 200ms 后触发 setTimeout(processAudio, 200); } processAudio(); }4.3 Vue 组件封装SpeechInput.vue的完整实现template div classspeech-input-wrapper div classspeech-control button clicktoggleRecognition :disabledisProcessing classspeech-btn span v-if!isRecognizing 开始说话/span span v-else⏹️ 正在识别.../span /button button clickclearInput :disabled!finalText classclear-btn 清空 /button /div div classspeech-display input v-modelfinalText inputonInput :placeholderplaceholder classspeech-input refinputRef / div refrealtimeLayer classrealtime-overlay v-showisRecognizing /div /div div v-iferror classerror-message {{ error }} /div /div /template script setup import { ref, onMounted, onUnmounted, nextTick } from vue; import BaiduRealtimeASR from /services/BaiduRealtimeASR; const props defineProps({ placeholder: { type: String, default: 点击按钮开始说话... } }); const emit defineEmits([update:modelValue, complete, error]); const inputRef ref(null); const realtimeLayer ref(null); const finalText ref(); const isRecognizing ref(false); const isProcessing ref(false); const error ref(); const asrEngine new BaiduRealtimeASR(); // 事件绑定 asrEngine.onPartialResult((text) { if (realtimeLayer.value) { realtimeLayer.value.textContent text; } }); asrEngine.onFinalResult((text) { finalText.value text; emit(update:modelValue, text); emit(complete, text); if (realtimeLayer.value) { realtimeLayer.value.textContent ; } }); asrEngine.onError((msg) { error.value msg; emit(error, msg); }); // 方法 const toggleRecognition async () { if (isRecognizing.value) { asrEngine.stop(); isRecognizing.value false; } else { isProcessing.value true; try { // 获取 token调用后端接口 const tokenRes await fetch(/api/auth/baidu-token); const { token, appid } await tokenRes.json(); await asrEngine.start(token, appid); isRecognizing.value true; // 聚焦输入框方便用户看到实时文本 await nextTick(); inputRef.value?.focus(); } catch (err) { error.value 获取语音服务凭证失败请检查网络; emit(error, error.value); } finally { isProcessing.value false; } } }; const clearInput () { finalText.value ; emit(update:modelValue, ); if (realtimeLayer.value) { realtimeLayer.value.textContent ; } }; const onInput (e) { emit(update:modelValue, e.target.value); }; // 生命周期 onMounted(() { // 监听页面可见性变化 document.addEventListener(visibilitychange, () { if (document.hidden isRecognizing.value) { asrEngine.pause(); } else if (!document.hidden isRecognizing.value) { asrEngine.resume(); } }); }); onUnmounted(() { asrEngine.destroy(); }); /script style scoped .speech-input-wrapper { position: relative; width: 100%; } .speech-control { display: flex; gap: 8px; margin-bottom: 12px; } .speech-btn, .clear-btn { padding: 8px 16px; border: none; border-radius: 4px; background: #409eff; color: white; cursor: pointer; font-size: 14px; } .speech-btn:disabled, .clear-btn:disabled { opacity: 0.6; cursor: not-allowed; } .speech-display { position: relative; } .speech-input { width: 100%; padding: 12px 16px; font-size: 16px; border: 1px solid #dcdfe6; border-radius: 4px; outline: none; } .speech-input:focus { border-color: #409eff; box-shadow: 0 0 0 2px rgba(64, 158, 239, 0.2); } .realtime-overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; padding: 12px 16px; pointer-events: none; color: #909399; font-size: 16px; line-height: 1.5; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .error-message { margin-top: 8px; color: #f56c6c; font-size: 12px; min-height: 16px; } /style4.4 后端代理与 Token 管理Node.js Express 示例server.jsExpress 后端const express require(express); const axios require(axios); const redis require(redis); const app express(); const client redis.createClient(); // 百度 OAuth2 配置 const BAIDU_CLIENT_ID process.env.BAIDU_CLIENT_ID; const BAIDU_CLIENT_SECRET process.env.BAIDU_CLIENT_SECRET; const BAIDU_OAUTH_URL https://aip.baidubce.com/oauth/2.0/token; app.get(/api/auth/baidu-token, async (req, res) { try { // 先查 Redis 缓存 const cached await client.get(baidu_access_token); if (cached) { return res.json(JSON.parse(cached)); } // 调用百度 OAuth2 接口 const response await axios.post(BAIDU_OAUTH_URL, null, { params: { grant_type: client_credentials, client_id: BAIDU_CLIENT_ID, client_secret: BAIDU_CLIENT_SECRET } }); const { access_token, expires_in } response.data; const expiresInMs (expires_in - 300) * 1000; // 提前 5 分钟过期 // 缓存到 Redis await client.setex(baidu_access_token, expiresInMs / 1000, JSON.stringify({ token: access_token, appid: your-appid-here // 从百度控制台获取 })); res.json({ token: access_token, appid: your-appid-here }); } catch (error) { console.error(Failed to get Baidu token:, error); res.status(500).json({ error: Token acquisition failed }); } }); // WebSocket 代理Nginx 更优此处为演示 app.get(/ws/baidu-asr, (req, res) { res.setHeader(Content-Type, text/plain); res.end(WebSocket proxy requires Nginx configuration); }); app.listen(3000, () { console.log(Server running on http://localhost:3000); });5. 常见问题排查与独家避坑指南那些文档里不会写的实战经验5.1 典型问题速查表问题现象可能原因解决方案WebSocket 连接失败报Error during WebSocket handshakeNginx 未正确配置Upgrade和Connectionheader检查 Nginxproxy_set_header是否包含Upgrade $http_upgrade和Connection upgrade首字识别延迟 500mscuid每次都变百度服务端新建会话耗时确保cuid从localStorage读取并持久化不要用Math.random()文本显示乱码如ä½ å¥½PCM 数据未按 16-bit signed integer 编码或字节序错误在AudioWorklet中确认Int16Array转换逻辑用new Int16Array(buffer).length验证长度Chrome 下识别准确率明显低于 SafariChrome 默认启用echoCancellation过度抑制人声在getUserMedia选项中显式关闭{ audio: { echoCancellation: false, noiseSuppression: false } }页面切到后台后识别中断AudioContext被浏览器 suspend监听visibilitychange事件在document.hidden为 true 时调用audioContext.suspend()恢复时resume()5.2 我踩过的三个深坑与解决方案坑一Safari 的AudioContext自动挂起陷阱Safari 15 为节省电量默认在页面不可见时 suspendAudioContext。即使你调用了resume()如果不在用户手势click/tap后执行会抛出

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑