
简介面向Vue.js开发者的科大讯飞流式语音听写实现方案不依赖Web Speech API而是通过WebSocket连接讯飞语音识别服务完成实时语音转文字并同时提供Vue.js组件版与原生HTML页面版双版本兼顾工程化与轻量接入两种场景。资源包共37个文件包含20个JavaScript脚本、6个Vue组件、2个Markdown使用说明、1个TypeScript文件、1个HTML页面与1个JSON配置压缩包仅112KB目录结构简洁js与vue分层明确适合直接移植到已有项目也适合作为学习流式语音交互的参考模板。目前已有2192人浏览学习。代码中细致演示了如何创建MediaStreamAudioSourceNode构建录音链路、通过WebSocket的send方法发送音频数据并监听onmessage接收结果同时利用Vue的data属性与生命周期钩子管理识别状态还包含断线重连与超时重试机制。这套实现能帮助开发者快速掌握结合实际前端框架与第三方语音服务的完整思路减少踩坑成本。 科大讯飞的语音听写能力在行业里用得非常多但真正把流式跑通的前端项目不算多。最近我在公司的 Vue.js 项目里接入了讯飞语音听写流式接口实现边说边识别、实时出字幕的效果整个过程踩了不少坑尤其是音频采样率、帧格式、WebSocket 状态管理这几个环节文档里写得比较简单实际调试起来相当考验耐心。这篇就把我在 Vue 3 TypeScript 项目中的完整实现思路、关键代码和避坑记录分享出来适合正在做实时语音识别、语音输入、会议转写这类需求的前端同学参考。1. 流式语音听写和普通语音识别根本不是一回事1.1 为什么方案必须选流式语音识别接口向来有两条路一条是先把音频录完形成一个完整文件再传上去等服务器识别完把整段文本返回另一条就是讯飞的语音听写流式接口麦克风采集到的音频一边产生一边推给服务器服务器也一边识别一边往回推文本片段。一次性上传在体验上有三个硬伤。第一是等待时间长录一分钟音频从上传到识别完可能要等十几秒甚至更久用户盯着一个转圈的按钮很煎熬。第二是交互被限制你想在用户说话的同时给他展示一个实时字幕都做不到因为结果在结束之后才回来。第三是音频体积问题小时级的会议录音文件动辄几百兆上传成本很高。流式方案就不一样了。音频帧按 40 到 60 毫秒的粒度切片实时推给服务端返回的是增量文本你只需要在界面上不断追加显示用户就能看到自己说一句、字幕出一句的效果。这种体验在语音输入法、直播字幕、客服质检这些场景里几乎是刚需。我接的这次需求是一个 Web 端的语音速记工具用户希望按下开始键后说话的同时文字就能在页面上不断蹦出来所以从一开始就锁定了流式听写。1.2 为什么用 WebSocket 而不是 SSE如果后端要往前端推数据很多人第一反应是 SSEServer-Sent Events但语音识别这个场景不是单向推送。前端要持续上传音频后端要持续返回识别结果这是一个双向实时通道SSE 只支持服务端到客户端推送音频上行还得另开 HTTP 接口两边同步会很别扭。WebSocket 天然支持双向二进制消息音频帧可以直接以 ArrayBuffer 传输文本结果也能在同一个连接里收回来一个连接干完所有事。讯飞语音听写流式接口本身就是基于 WebSocket 协议的所以前后端之间也用 WebSocket 对齐是最自然的选择。2. 整体链路设计前端其实不应该直连讯飞2.1 三层结构各干各的活我最终落地的架构是经典的三层Vue 前端负责录音、音频分帧、发送和结果展示中间一层是一个 Node.js 转发服务负责生成鉴权 URL、建立与讯飞的 WebSocket 连接、双向转发数据第三层才是讯飞开放平台的语音听写流式接口。Vue 前端 -- WebSocket -- Node.js 转发服务 -- WebSocket -- 科大讯飞 IAT这里前端只管两件事把麦克风数据变成符合格式的 PCM 音频帧以及把收到的识别文本渲染到页面上。所有跟讯飞鉴权、连接维护有关的逻辑都收在 Node 服务里。这样做表面上好像多了一层多绕了一道但从安全性和可维护性两个角度看都很值得。2.2 前端直连讯飞的三个致命问题第一个是密钥安全。讯飞接口需要一个 APIKey 和一个 APISecret 来生成动态鉴权 URL如果把这两个东西写在前端代码里打包后任何人都能从浏览器控制台的 Network 面板里看到完整的 WebSocket URL等于把你的账号资源拱手送人。第二个问题是跨域和网络策略浏览器环境对 WebSocket 连接的目标地址有严格限制前端直接连讯飞域名经常会遇到握手被拒、CSP 拦截之类的问题排查起来非常麻烦。第三个问题是缺少容错空间直连模式下连接断了就只能让用户重新开始而通过后端中转可以在后端做断线重连、日志留存、并发限制甚至以后把音频转写服务替换成别家前端代码都不用动。3. 鉴权 URL 生成这一步错一步后面全白搭3.1 签名算法拆解讯飞语音听写流式接口的 WebSocket URL 不是固定的每次连接前要动态拼一个带鉴权参数的 URL参数有四个host、date、authorization、signature。核心是 authorization 里面的 signature。生成流程分三步。第一步取一个符合 RFC1123 格式的 UTC 时间字符串比如 Wed, 18 Jun 2025 06:48:44 GMT这个时间必须是 UTC 时间很多同学直接用本地时间导致签名校验失败。第二步把三行内容拼成一个字符串它就是待签名的原始字符串host: iat-api.xfyun.cn date: Wed, 18 Jun 2025 06:48:44 GMT GET /v2/iat HTTP/1.1第三步用 APISecret 作为密钥对这个原始字符串做 HMAC-SHA1 计算把结果做 Base64 编码得到 signature。然后把 APIKey、签名算法、headers、signature 拼成 authorization 字符串api_key你的APIKey, algorithmhmac-sha1, headershost date request-line, signature上一步算出的signature最后把 authorization 和 date 做 URL 编码拼到 wss://iat-api.xfyun.cn/v2/iat 后面。整个流程看起来繁琐但拆开看就是三板斧拼明文、用密钥签名、拼 URL。3.2 Node.js 后端实现示例我用 Node.js 内置的 crypto 模块实现不引入额外依赖const crypto require(crypto); function buildIatUrl(apiKey, apiSecret) { const host iat-api.xfyun.cn; const path /v2/iat; const date new Date().toUTCString(); const signatureOrigin host: ${host}\ndate: ${date}\nGET ${path} HTTP/1.1; const signature crypto .createHmac(sha1, apiSecret) .update(signatureOrigin) .digest(base64); const authorization api_key${apiKey}, algorithmhmac-sha1, headershost date request-line, signature${signature}; const url wss://${host}${path}?authorization${encodeURIComponent(authorization)}date${encodeURIComponent(date)}host${host}; return url; }这段代码我在多个项目里复用过只要 APIKey 和 APISecret 没错生成的 URL 基本都能一次通过。需要注意 toUTCString() 产出的格式就是 RFC1123所以不需要自己手动拼日期字符串。4. Vue 端音频采集从麦克风到 PCM16 字节流4.1 获取麦克风并统一采样率前端要做的第一件事是拿到麦克风音频。在浏览器里统一走 MediaDevices APIconst stream await navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, sampleRate: 16000, echoCancellation: true, noiseSuppression: true, autoGainControl: true } });这里有个容易踩的坑sampleRate 参数只是给浏览器的建议值不是强制值。在 Chrome 里很多 USB 麦克风或耳机的默认采样率是 48000Hz即使你写了 16000浏览器也可能原样返回 48000 的音频流。如果直接把这种数据推给讯飞识别结果会乱套所以必须在代码里做一次重采样。我的做法是创建音频上下文时指定采样率如果不行再手动转换。稳妥一点的方案是新建一个 AudioContext把从 getUserMedia 拿到的流通过 MediaStreamAudioSourceNode 接进来再用 ScriptProcessorNode 或 AudioWorklet 按 16000Hz 的预期去处理。Vue 组件里我封装了一个 Recorder 类核心逻辑是const audioContext new AudioContext({ sampleRate: 16000 }); const source audioContext.createMediaStreamSource(stream); const processor audioContext.createScriptProcessor(4096, 1, 1); source.connect(processor); processor.connect(audioContext.destination);如果浏览器不支持指定 sampleRate可以退回到默认采样率然后在处理回调里做线性插值重采样。实际项目里 16000 在大多数主流浏览器都能直接生效这个降级分支主要是以防万一。4.2 把 Float32 音频转成 16bit PCMWeb Audio 接口里的音频数据是 Float32 格式范围在 -1 到 1 之间而讯飞要求的是 16kHz、16bit、单声道的 PCM 数据。转换过程就是浮点转整数再加字节序处理function floatTo16BitPCM(float32Array) { const int16Array new Int16Array(float32Array.length); for (let i 0; i float32Array.length; i) { const sample Math.max(-1, Math.min(1, float32Array[i])); int16Array[i] sample 0 ? sample * 0x8000 : sample * 0x7fff; } const buffer new ArrayBuffer(int16Array.length * 2); const view new DataView(buffer); for (let i 0; i int16Array.length; i) { view.setInt16(i * 2, int16Array[i], true); } return new Uint8Array(buffer); }注意 setInt16 的第三个参数传 true表示用 little-endian 字节序。讯飞文档里要求的就是小端序如果这里写错识别结果会变成乱码或者完全识别不出来。4.3 分帧发送攒够 40ms 再说话ScriptProcessorNode 的回调每次会传来固定大小的音频块比如 4096 个采样点在 16kHz 下就是 256ms 的音频。但讯飞建议每次发送的音频帧在 40ms 到 60ms 之间不能把 4096 个采样点一整包丢过去。所以要把回调数据先缓存起来攒够一帧的长度再发送。const FRAME_SAMPLES 16000 * 0.04; // 40ms 对应 640 个采样点 let buffer []; processor.onaudioprocess (event) { const channelData event.inputBuffer.getChannelData(0); buffer.push(...channelData); while (buffer.length FRAME_SAMPLES) { const frame new Float32Array(FRAME_SAMPLES); for (let i 0; i FRAME_SAMPLES; i) { frame[i] buffer.shift(); } sendPcmFrame(floatTo16BitPCM(frame), 1); } };这里 buffer.shift() 在数组很长时性能会变差实际项目里更推荐用维护一个 offset 指针的方式来切帧避免频繁 shift 大数组。不过在 40ms 粒度下每帧只有几百个元素性能影响其实可接受先用这个写法保证可读性。5. WebSocket 帧协议与前端状态机5.1 音频帧的二进制封装讯飞语音听写流式接口的 WebSocket 消息分两种上行是二进制音频帧下行是文本 JSON。音频帧的格式非常规整前 4 个字节是帧头后面跟音频数据字段长度说明frameSize2 字节整个帧的字节数高位在前frameType1 字节0x00 表示音频数据status1 字节0 首帧1 中间帧2 末帧data可变音频 PCM 数据前端封装代码如下function createAudioFrame(pcmData, status) { const headerSize 4; const buffer new ArrayBuffer(headerSize pcmData.byteLength); const view new DataView(buffer); view.setUint16(0, buffer.byteLength, false); view.setUint8(2, 0x00); view.setUint8(3, status); new Uint8Array(buffer, headerSize).set(pcmData); return buffer; }setUint16 第三个参数传 false表示大端字节序这是协议固定的别写成 true。5.2 发送逻辑与状态流转我习惯把整个录音识别过程抽象成几个状态idle 空闲、connecting 连接中、recording 录音中、stopping 停止中、finished 已完成。Vue 组件里用一个 ref 来维护控制按钮的禁用状态和提示文案都跟着它走。开始录音的流程是先初始化麦克风和 AudioContext然后让后端建立与讯飞的连接等 WebSocket 真正 open 之后前端才开始发送音频数据。第一帧音频的 status 是 0中间帧是 1用户点击停止时发送一个 status 为 2 的空音频帧表示音频流结束。之后服务端会返回最终识别结果再等连接关闭。这里有一个非常容易犯的错点击停止后不要立刻调用 ws.close()。如果前脚把结束帧发出去后脚就把连接关了服务端还没来得及返回最后一段文本连接就被掐断用户会看到最后一句话丢失。正确做法是先发 status2 的结束帧然后等服务端返回 code 为 0 的完成标志或者收到 status 为 2 的结果帧后再关闭连接。5.3 识别结果的解析与渲染讯飞返回的是 JSON 文本结构大致如下{ code: 0, data: { status: 1, result: { sn: 2, ws: [ { bg: 0, cw: [{ w: 你好 }] } ] } } }前端要做的就是把 data.result.ws 数组里的每一项取 cw[0].w拼接到当前句子上。每次返回都是增量内容直接追加即可。Vue 层可以暴露一个回调把文本不断推给上层组件配合 v-model 或者一个 ref 就能实现实时字幕展示。如果不需要逐字效果也可以只在每句话结束时刷新一次看产品需求。6. 踩坑记录与避坑清单6.1 高频问题速查表把这次开发中遇到的主要问题汇总成一张表后续接讯飞的同学可以对照排查问题现象可能原因解决办法连接返回 401鉴权 URL 生成错误检查 date 是否为 UTC 时间signature 是否用 APISecret 签名能连上但识别结果乱码PCM 字节序反了setInt16 和 setUint16 要区别对待音频数据小端帧大小大端识别结果全部为空音频采样率不是 16000在 AudioContext 里强制 16000或做重采样语音结束后等很久不出结果没发送 status2 的结束帧停止时发送空音频帧status 置 2点击停止后最后几个字丢失提前关闭了 WebSocket等收到完成标志后再 close页面切后台再回来连接断了移动端 WebSocket 被系统挂起监听 visibilitychange做重连并提示用户重新录音6.2 几个值得注意的细节技巧第一录音过程中如果出现音频回调里有大量 0 值基本可以断定是降噪或者回声消除把声音消过头了。尤其是 Chrome 在某些设备上默认启用强降噪说话声音小的时候会被当成噪音处理识别结果变成空文本。遇到这种情况可以把 noiseSuppression 临时关掉测试对比很多时候问题立刻消失。第二关于结束帧我发现讯飞对 status2 的音频帧并不要求必须有音频数据所以可以直接 send 一个只有 4 字节头、data 为空的帧。这个空帧的作用纯粹是告诉服务端我说完了不用纠结里面有没有数据。第三Vue 组件卸载时要记得做清理。很多人只写了开始录音忘了在 onUnmounted 里停掉麦克风、关闭 AudioContext、断开 WebSocket导致组件销毁后麦克风指示灯还亮着甚至继续往服务端传音频。正确做法是统一封装一个 dispose 方法把 stream.getTracks() 全部 stop 掉再关闭连接。第四如果想让体验更接近真实语音输入法可以在前端加一个基于 AnalyserNode 的静音检测连续 1.5 秒没检测到声音就自动结束录音。这样用户说完一句话不用手动点停止识别结束更自然。这块逻辑不复杂但能明显提升产品体验。7. 顺手做了一次组件化封装7.1 useIat 组合式函数的接口设计完成第一版功能后我做的第一件事就是把代码重构成一个组合式函数 useIat。Vue 3 的 Composition API 很适合这种场景把录音、WebSocket、状态管理全部收进一个模块里页面组件不用感知底层细节。// useIat.ts const { start, stop, isRecording, finalText, partialText } useIat();start 里完成麦克风初始化、WebSocket 连接、状态切换到 recordingstop 里发送结束帧、等待结果、清理资源。partialText 用来展示实时字幕finalText 用来保存最终完整文本。页面只需要在模板里绑定这两个 ref再加两个按钮事件一个可用的语音速记功能就成型了。7.2 封装带来的复用价值封装带来的收益很快就显现出来了。同一个仓库里我后来又做了搜索框的语音输入和直播间的实时字幕两个需求前者需要把 partialText 塞进搜索框后者需要把文本推给弹幕消息列表但底层录音和识别逻辑完全一样我一行都没改地直接复用了。如果以后讯飞这套音频帧协议有变化也只需要改 useIat 这一个文件所有页面统一感知。我个人在实际项目里的体会是接讯飞这种流式接口最大的门槛其实不在接口本身而在音频工程这些细枝末节上。格式对不对、状态怎么流转、连接什么时候不能关这些才是真正决定上线后识别率和使用体验的地方。把这些整理清楚后面再迁到其他平台或者加新功能都会从容很多。本文还有配套的精品资源点击获取