资讯动态

Vue.js语音听写流式实现:IAM签名、VAD与音频采集全解析

发布时间:2026/10/18 16:32:10 来源:尧图企业网站定制
简介面向Vue.js前端开发者一份结合科大讯飞语音识别API与WebSocket的流式语音听写实现方案。资源提供Vue.js与原生HTML双版本通过Web Audio API录制麦克风音频并实时发送至讯飞服务完整演示了WebSocket连接建立、语音数据流式上传、识别结果接收与前端实时展示等核心流程并包含错误处理与重试机制示例适合需要为项目快速加入语音转文字功能或深入理解实时通信原理的开发者。包体共37个文件以js和vue源码为主另有html示例、json配置、markdown说明文档及TypeScript文件压缩包仅112KB结构清晰便于直接检索调试。已有2203人学习下载可作语音交互类项目的轻量参考。资源亮点在于双版本实现对比Vue组件化设计展示如何利用生命周期钩子和响应式状态管理集成语音结果传统HTML页面则演示无框架下的原生DOM操作方式。同时包内含iat-js-demo与speech2text模块从底层工具封装到业务页面展示层次分明对希望掌握讯飞语音接口调用、WebSocket实时传输或前端录音链路的开发者是一份可直接运行的起步范本。1. 语音听写流式实现为什么边说边识别才是关键做语音笔记和会议纪要这类产品时我最初图省事想着等用户录完一整段再调一次讯飞听写接口结果界面卡死、保存失败、识别结果和现场讲的对不上折腾两轮后只能老老实实换回流式方案。这篇文章就是把基于 Vue.js 的语音听写流式实现拆开讲从 IAM 签名、WebSocket 建连、音频采集、VAD 静音检测、编码兼容到最后的避坑记录重点不是怎么把音频发给接口而是怎样让一段会话从采集、鉴权、发送到结束帧全程走得顺、延迟可控。适合正在用 Vue.js 做语音笔记、实时字幕、会议转写并且想在浏览器里直接打通讯飞 WebSocket 听写接口的从业者需要一丁点 TypeScript 基础和 WebSocket 概念。2. 签名与建连IAM 鉴权五个参数错一个就全流程不通讯飞 WebSocket 听写接口走的是 IAM 鉴权连wss://iat-api.xfyun.cn/v2/iat时不能像老版接口那样往 Header 塞一个 token 就完事而是要把authorization、date、host全部拼进 URL 查询参数。最让我意外的是这个 URL 里的authorization并不是固定的它依赖当前 UTC 时间参与签名过期后原 URL 就不能复用。很多 demo 里把签名写成死的结果第二天就跑不通原因就在这里。2.1 为什么签名的活最好放在后端做浏览器直连有一个很现实的坑WebSocket 的 URL 不支持自定义 Header讯飞要求的鉴权信息必须拼在 URL 里而 URL 一旦暴露在控制台 Network 面板APIKey 和 APISecret 基本等于裸奔。我在生产项目里一律用 Node.js 后端代理前端只向后端要一个带签名的 WebSocket 地址后端负责算签名、给 URL 设置有效期。这样 APIKey 不落地到浏览器包体哪怕页面被别人扒走也只能拿到一个几分钟后失效的 URL。有段时间我试过在前端用 Web Crypto API 算签名Promise 链写起来倒不复杂但最大的问题不是性能是密钥暴露在打包产物里反编译后就能直接把接口刷到限额。这一点很多开源 demo 都不当回事可一旦做成正式产品被薅的代价会远大于那次省一台后端的愉悦。2.2 手写签名host、date、authorization 一个都不能少签名生成的完整流程是这样先取一个 RFC 1123 格式的 UTC 时间比如Tue, 18 Jun 2024 09:00:00 GMT再拼一个待签名字符串里面包含三行内容分别是host、date、request-line最后用 APISecret 作为 HMAC 密钥做 SHA256 运算输出 base64 字符串。这个request-line很容易写错它必须长成GET /v2/iat HTTP/1.1这样少了HTTP/1.1或以https://开头签名校验都会挂。const crypto require(crypto); // 生成 authorization 字符串不负责维护状态 function buildAuthorization({ host, path, apiKey, apiSecret, date }) { // headers 参数顺序固定host、date、request-line换行是 \n const signingStr [ host: ${host}, date: ${date}, request-line: GET ${path} HTTP/1.1, ].join(\n); // 以 APISecret 作为 HMAC 密钥算法固定为 sha256 const signature crypto .createHmac(sha256, apiSecret) .update(signingStr) .digest(base64); return [ api_key apiKey , algorithmhmac-sha256, headershost date request-line, signature signature , ].join(, ); }逻辑说明authorization里的headershost date request-line必须和signingStr里实际参与签名的三行一一对应多写一个content-type或少写一个都会导致服务端验签失败。参数方面host只写域名不带路径path以/开头且不带 querydate必须用 UTC 格式不能用本地时间。拿到authorization后再拼出完整 WebSocket 地址// express 路由片段给前端下发带签名的连接地址 app.get(/asr/token, (req, res) { const date new Date().toUTCString(); const path /v2/iat; const host iat-api.xfyun.cn; const authorization buildAuthorization({ host, path, apiKey: process.env.XFYUN_API_KEY, apiSecret: process.env.XFYUN_API_SECRET, date, }); const url wss://${host}${path} ?authorization${encodeURIComponent(authorization)} date${encodeURIComponent(date)} host${host}; res.json({ url, expiresAt: Date.now() 8 * 60 * 1000 }); });这里有个细节authorization、date拼进 query 时必须encodeURIComponent因为authorization里的双引号和逗号会被 URL 解析器截断。我一般把 URL 有效期设成 8 分钟和讯飞文档里提到的有效期保持同一量级避免前端拿到地址后用户思考半天再点录音时已经失效。2.3 连接状态机与指数退避重连WebSocket 连接断开在弱网环境下非常常见语音识别这种实时服务比普通接口对重连要求更高。我见过不少实现是onclose里直接setTimeout(connect, 3000)真到网络抖动时瞬间打出几十个连接服务端限流直接触发。正确思路是记录重试次数按 2 的幂次递增间隔。class AsrConnection { constructor() { this.retry 0; this.ws null; this.closedByUser false; } connect(url, handlers) { return new Promise((resolve, reject) { this.ws new WebSocket(url); this.ws.onopen () { this.retry 0; resolve(); }; this.ws.onmessage (ev) { const data typeof ev.data string ? JSON.parse(ev.data) : ev.data; handlers.onMessage(data); }; this.ws.onerror (err) reject(err); this.ws.onclose () { if (this.closedByUser) return; this.scheduleReconnect(url, handlers); }; }); } scheduleReconnect(url, handlers) { const delay Math.min(1000 * 2 ** this.retry, 15000); this.retry 1; setTimeout(() this.connect(url, handlers), delay); } close() { this.closedByUser true; this.ws this.ws.close(); } }逻辑说明onerror触发后通常紧接着会触发onclose所以在onerror里不做重连只 reject避免一次断线触发两次重连。指数退避的初始值 1 秒、上限 15 秒重试次数超过 5 次后间隔不再增长。注意每次重连成功后要重置retry否则累加下去会把下一次重试也拖得很慢。3. 音频采集与本地 VAD把静音切掉识别才会跟手早期版本我把麦克风原始流一古脑往讯飞推用户说到一半停住思考服务端还在傻等数据十来秒后返回一段错乱的文本随后连接被服务端掐掉。后来引入本地 VAD 做静音门控体验立刻上了一个台阶只有检测到人声时才发送音频帧停顿超过设定阈值就主动发结束帧识别结果按句返回不再等一整段录完。3.1 采集参数16kHz 16bit 是服务端预期讯飞听写服务期待的音频格式是 16kHz 采样率、16bit 位深、单声道 PCM。浏览器getUserMedia默认给出的流通常是 48kHz 双声道MediaRecorder封装后的 Blob 也是按浏览器默认编码来的不能直接往讯飞塞。我的采集链路是getUserMedia拿到流 →MediaRecorder分包 → 拿到 ArrayBuffer 后做重采样 → 交给后续 VAD 与发送循环。async function startCapture(onRawChunk) { const stream await navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true, autoGainControl: true, }, }); const recorder new MediaRecorder(stream); recorder.ondataavailable async (e) { if (e.data e.data.size 0) { const arrayBuf await e.data.arrayBuffer(); onRawChunk(arrayBuf); } }; recorder.start(250); // 每 250ms 给一个 chunk return { stream, recorder }; }逻辑说明channelCount: 1只是让采集端尽量输出单声道不保证最终编码格式echoCancellation、noiseSuppression、autoGainControl三个约束在浏览器里属于建议级别用户系统和硬件不支持时会被忽略。recorder.start(250)表示每 250ms 触发一次ondataavailable这个间隔不能太小否则主线程会被频繁回调打满也不能太大否则流式延迟会明显拉高。这里最麻烦的是重采样。MediaRecorder输出的 Blob 是浏览器按自身编码封装的要转成 16k 16bit PCM常规做法是交给AudioContext解码后再重采样。我在 Worker 里做免得每 250ms 一次的解码操作把 UI 帧率拖垮。function downsampleBuffer(buffer, inputRate, outputRate) { if (inputRate outputRate) return buffer; const ratio inputRate / outputRate; const newLength Math.round(buffer.length / ratio); const result new Float32Array(newLength); // 这里用线性插值代替简单跳采语音场景下减少锯齿感 for (let i 0; i newLength; i) { const pos i * ratio; const idx Math.floor(pos); const frac pos - idx; const a buffer[idx] || 0; const b buffer[idx 1] || 0; result[i] a (b - a) * frac; } return result; }逻辑说明pos是源采样位置idx取整后拿到相邻两个采样点再用frac做线性插值。直接用Math.round(i * ratio)跳采也能出结果但人声在句尾的尾音会被切得生硬我建议至少用一次线性插值开销不大识别率会稳一些。3.2 本地 VAD 静音检测用 ONNX 模型判断说话概率VAD 有很多实现思路能量阈值、过零率、频域特征、神经网络。我最终选的是 Silero VAD 模型原因是它对环境噪声的鲁棒性远好于简单能量门限而且可以导出 ONNX 格式配合onnxruntime-web在浏览器里直接跑不需要把音频上传到服务端再等回调。模型输入是 16k 单声道、每次 512 个采样点约 32ms输出是这一帧的说话概率。我第一次踩的坑是没有把 VAD 推理放进 Worker。Silero 模型不大但逐帧推理的累积开销不小跑在主线程上页面滚动和按钮点击都会出现肉眼可见的卡顿。后来我把加载模型、推理、阈值判断全部搬进独立 Worker主线程只接收说话开始说话结束这类布尔事件。import * as ort from onnxruntime-web; let sessionPromise; function loadVADSession() { if (!sessionPromise) { sessionPromise ort.InferenceSession.create(/models/silero_vad.onnx, { executionProviders: [wasm], graphOptimizationLevel: all, }); } return sessionPromise; } async function detectSpeechProb(pcmFrame) { const session await loadVADSession(); const tensor new ort.Tensor(float32, pcmFrame, [1, 512]); const outputs await session.run({ input: tensor }); return outputs.output.data[0]; // 范围 0~1 }逻辑说明这里每次调用session.run只处理一个 32ms 帧真实项目里我一般把 VAD 放在 Worker 的循环里一个 250ms chunk 切出 7 到 8 帧按顺序跑。executionProviders: [wasm]是兼容性最好的配置如果只面向桌面 Chrome可以优先用webgpu延迟会进一步下降但 iOS 上回退逻辑要写好。拿到每帧说话概率后需要做一个简单的状态机连续几帧超过阈值才判定为在说话否则噪声尖峰可能误触发。反之持续低概率超过设定时长就认为一句话说完了。3.3 发送循环与会话状态事件总线让 UI 只关心结果VAD 的输出不能直接作为发送频率的依据因为讯飞要求音频帧需要按固定节奏发送且每个 WebSocket 消息最多带一个固定长度的帧头加音频数据。我通常的做法是主线程把重采样后的 PCM 放进一个队列发送循环用定时器驱动每次从队列取出一段音频判断当前 VAD 状态再组装成带帧头的二进制消息发出去。// 组装讯飞 WebSocket 音频帧固定 8 字节帧头 function buildAudioFrame(status, audioData) { const buf new ArrayBuffer(8 audioData.byteLength); const view new DataView(buf); view.setInt32(0, 0x800, false); // frame 字段固定 2048 view.setInt32(4, status, false); // 0第一帧1中间帧2最后一帧 new Uint8Array(buf, 8).set(audioData); return buf; }逻辑说明frame固定是 2048status表示音频帧位置首帧、中间帧、尾帧三种状态必须按顺序发送。这里最容易出错的是setInt32最后一个参数false它表示按大端序写入讯飞协议里帧头字段按网络字节序解析如果漏掉这个参数默认按主机序写入在 x86 机器上通常是小端服务端就会解析出乱码或直接报Invalid v2 package。这个坑我当时查了一整天后面避坑章里会再展开。音频帧从队列发出去之后VAD 的说话结束信号还要能反过来打断发送循环。我设计的事件总线大概长这样type AsrEvent | { type: speechStart; ts: number } | { type: speechEnd; ts: number } | { type: result; text: string; stable: boolean; sn: number } | { type: status; state: idle | connecting | listening | reconnecting }; const emitter mittRecordAsrEvent[type], (e: any) void();发送循环在收到speechEnd后不再从队列取新音频而是补发一个status 2的结束帧然后等待服务端返回最终识别结果。这个等待结果的状态非常重要如果发完结束帧立刻关连接最后一句的文本大概率丢。4. 音频格式兼容与转码Safari 不出声往往跟编码有关在浏览器里做语音采集绕不开MediaRecorder的格式支持差异。Chrome 默认输出audio/webm;codecopusFirefox 类似但 Safari 的表现就要复杂得多部分版本根本不支持MediaRecorder部分版本只支持audio/mp4。如果你把 Chrome 上调通的代码原封不动搬到 iPad大概率会遇到开始录音后没有任何音频数据的尴尬。4.1 各端 MediaRecorder 支持矩阵下面是我在实际项目里维护的一个支持情况对照表基于我自己手头的设备和同事的测试机不是官方文档的全量结论浏览器环境MediaRecorder 是否可用常见输出格式备注Chrome 桌面 90可用audio/webm;codecopus最省心Edge 桌面 90可用audio/webm;codecopus个别版本断流需要重连Firefox 桌面可用audio/webm;codecopus一切正常Safari 桌面 16可用但行为不稳定audio/mp4需要转 PCM/MP3 再发iOS Safari 15部分版本 API 存在但不触发数据audio/mp4或空建议走 Web Audio 采集兜底这张表的结论是不要假定录音代码在所有浏览器上以相同格式输出跨端必须做降级。我的降级顺序是优先用MediaRecorder监听dataavailable是否真正触发如果 1 秒内没有任何数据立刻切到AudioContextScriptProcessorNode/AudioWorklet直接采集 PCM。前端编码兼容再做一层输出格式统一为 16k 16bit PCM 给讯飞。4.2 浏览器内转码兜底重采样 MP3 / PCM不少后端同学会建议浏览器直接把webm/opus原样 POST 给后端由后端转码。但如果做的是纯前端方案或者后端不愿意处理 Opus 这种容器格式前端就得自己兜底。最实用的兜底方案是把MediaRecorder得到的 Blob 放进AudioContext解码成AudioBuffer重采样到 16k再按需输出 Float32Array 或转成 Int16Array。async function blobToInt16PCM(blob) { const arrayBuf await blob.arrayBuffer(); const audioCtx new AudioContext({ sampleRate: 16000 }); const audioBuffer await audioCtx.decodeAudioData(arrayBuf); const chan audioBuffer.getChannelData(0); const pcm downsampleBuffer(chan, audioBuffer.sampleRate, 16000); const int16 new Int16Array(pcm.length); for (let i 0; i pcm.length; i) { const s Math.max(-1, Math.min(1, pcm[i])); int16[i] s 0 ? s * 0x8000 : s * 0x7fff; } audioCtx.close(); return int16; }逻辑说明先decodeAudioData把 Blob 解码成标准AudioBuffer再取第 0 声道做重采样最后把Float32Array范围 [-1, 1] 映射到 Int16。注意AudioContext实例用完后要close()否则每次转码都会留一个音频上下文在后台开多几次内存就上去了。如果后端要求 MP3 而不是 PCM前端可以引入 lamejs 编码但 lamejs 是纯 JS 实现编码一段 3 分钟音频会把主线程卡死。我试过一次在 React 组件里直接转页面直接白屏两三秒。后来改成把Int16Array传给一个 Worker在 Worker 内完成 MP3 编码再回传主线程只收到一个 Blob URL。4.3 后端中转的取舍前端转码逻辑复杂必要时可以把解码 重采样 格式转换整个丢给后端。常见做法是前端拿到MediaRecorder的分片 Blob通过XMLHttpRequest或fetch分片上传后端按顺序拼回 PCM 或 MP3再转发给讯飞。后端中转的好处是省去前端性能瓶颈坏处是引入一层 HTTP 上传延迟对边说边识别这种场景不够友好。我只有在两种情况下才走后端中转一是必须兼容非常老的 WebView前端跑不动 VAD 和转码二是产品本身有合规要求音频必须落一份到自有服务器。常规的现代浏览器项目我仍然推荐前端直连讯飞 WebSocket不要让音频在局域网和服务器之间绕一圈。5. 常见问题避坑从签名失败到结果乱序的五条现场记录这章把我实际调试过程中踩过、以及帮同事排查过的坑按现象 → 原因 → 解决的结构记下来。有些问题光是错误文案就能把人骗到完全错误的方向值得单独留一笔。5.1 签名验证失败日期缓存是祸根现象昨天还能连上 WebSocket 的 URL今天一直报 10163 签名验证失败重新部署后端也一样。原因我在后端把签名 URL 做了缓存前端拿到的date是昨天生成的authorization 里参与签名的也是昨天的 date。讯飞校验时会用当前时间判断签名是否过期失效后即使原始签名串没变也会被拒绝。解决缓存穿透到 URL 本身而是只缓存 APISecret 和 APIKey 的读取每次请求都重新取new Date().toUTCString()重新签名。并且给前端返回expiresAt前端在过期前 30 秒主动向后端要新 URL避免用户录音途中连接被服务端断开。提示不要为了省几次签名计算而缓存一个跨小时的 URL讯飞的 IAM 签名有效期较短缓存收益远小于故障代价。5.2 连接 12 秒自动断开没看清服务端超时机制现象WebSocket 连上了也发了第一帧音频但大约 12 秒后连接被服务端关闭日志里只显示onclose没有明确的错误码。原因讯飞服务端会持续检测上行音频数据。如果长时间没有收到新的音频帧或者收到的帧数据不满足帧长要求服务端按任务超时处理会主动断连。我当时只发了首帧后续帧因为 VAD 判断一直没到阈值导致服务端认为任务已经结束。解决在 VAD 状态机里增加静音时长保护即使没有识别到说话只要连接处于listening也要定时发送低能量的填充帧或小帧保持上行活跃。同时确认每个音频帧的字节数符合预期16k 采样下每 40ms 是 1280 字节帧头另算。5.3 一句话被切碎VAD 阈值太低、结果没合并现象识别结果总是一个词一个词蹦出来比如今天 天气 真不错而不是一整句今天天气真不错。原因两层因素叠加。一是 VAD 阈值调得偏低句中的轻微停顿也被当成静音提前触发了结束帧二是前端收到服务端返回的多个result后只做简单追加没有判断服务端给出的结果是否稳定。解决把 VAD 的说话概率阈值从 0.3 提高到 0.5并把静音判定时长从 400ms 调整到 800ms 左右避免正常换气被切断。对服务端返回结果维护一个sn序号缓冲同一句话内的中间结果只更新 UI 预览等收到status 2的最后结果后才把文本提交到最终记录里。5.4 结果乱序多连接并行发文本现象页面同时开了两个语音听写连接一个处理麦克风实时输入一个处理后台播放的音频文件识别结果回来时顺序错乱两句内容串在一起。原因WebSocket 本身按序传消息但多个连接之间没有先后关系。我在界面上一股脑把两个数据源的结果都塞进同一个文本流顺序自然乱。解决为每一次识别会话生成独立的sessionId在事件总线里带上这个 IDUI 层按 ID 区分渲染通道。如果同一时间只能有一个活跃会话就在创建新连接前主动关闭旧连接避免并发写同一块展示区域。5.5 帧头字节序错了说好的大端一个不能改现象音频帧发出去后服务端没有任何识别结果返回或者 WebSocket 直接报错断开后端日志里能看到类似Invalid v2 package的提示。原因讯飞协议里帧头字段按网络字节序大端传输我一开始用TypedArray直接写入数字默认走小端结果服务端解析出来的帧长度和状态完全错乱。解决强制用DataView.setInt32(offset, value, false)第三个参数false表示大端序。不仅是帧头后续读取服务端返回的二进制帧时也要注意同样的问题。这个坑一但踩中光靠代码 review 很难查出来最好在发送前打印new Uint8Array(buf).slice(0, 8)人工核对第一帧数据是否正确。6. 最后一步用短音频自检把延迟抠干净流式语音识别的代码跑通只是开始真正让人头疼的是延迟漂移有时候首字很快整句却迟迟不出有时候网络一抖重连后 VAD 状态和发送队列对不上。我的做法是固定准备一段 32 秒的本地测试音频从 10 秒处有人声、中间带两次 0.8 秒停顿专门用来验证各种边界。这段音频不经过麦克风直接通过AudioContext解码成 PCM喂给同一个发送链路保证每次改动前后测的是同一场景。自检时我会在代码里埋几个时间点用一张表记录每一跳的耗时阶段耗时口径合理范围备注采集到重采样从ondataavailable到 PCM 入队列10-40msWorker 内完成VAD 决策从 PCM 入队到说话概率输出5-30ms单帧推理网络上行从ws.send到服务端 ACK50-200ms视带宽服务端首字从首帧发送到首个result300-800ms讯飞侧处理最终结果从结束帧到status2100-500ms受句长影响这个表不是标准答案我调了半个月后凭自己的网络环境定的经验值。如果首字延迟超过 1 秒我会先看是不是 VAD 阈值太高导致人声开头被切掉了一部分如果最终结果迟迟不来我会检查是否在发送结束帧后没有等待status2就关闭了连接。再给一个控制台自检脚本的思路放在 Vue 项目的src/utils/debug.ts里只在非生产环境开启。const timings { firstAudioAt: 0, firstResultAt: 0, finalResultAt: 0, }; session.on(audio, () { if (!timings.firstAudioAt) timings.firstAudioAt performance.now(); }); session.on(result, (e) { if (!timings.firstResultAt) timings.firstResultAt performance.now(); if (e.final) timings.finalResultAt performance.now(); });逻辑说明session是自己封装的识别会话模块把audio、result事件都抛到事件总线上。自检时打开控制台观察timings对象如果firstResultAt - firstAudioAt与上表的差异过大就要针对具体信道排查。从那以后我每次改动音频处理或 VAD 参数都会强制走一遍这段 32 秒音频的回归确认首字延迟没有上涨、句尾没有被切断、重连后队列没有残留旧数据然后再去调 UI 或加新功能。这套自检流程帮我省掉了至少三次线上事故级别的翻车。如果你也正在做 Vue.js 语音听写流式实现建议把类似的回归脚本沉淀到项目里后面维护起来会轻松很多。希望这篇记录能帮你少踩几个坑。本文还有配套的精品资源点击获取

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

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

免费获取报价 →
↑