资讯动态

Vue.js+WebSocket实现科大讯飞流式语音听写:从麦克风到实时字幕的完整指南

发布时间:2026/10/18 15:21:10 来源:尧图企业网站定制
简介一套基于科大讯飞语音识别服务的流式语音听写实现方案面向需要在前端接入实时语音转文字功能的Vue.js开发者也兼容传统HTML页面场景。方案通过WebSocket建立与讯飞服务的全双工通信利用Web Audio API采集麦克风音频并给出Vue组件化与原生DOM操作两种实现版本便于按项目架构灵活选用。压缩包共含37个文件以20个JavaScript脚本和6个Vue组件为核心辅以TypeScript类型定义、HTML页面、JSON配置及Markdown文档分别覆盖核心逻辑、界面封装、类型约束和使用说明整体仅112KB轻量易部署适合直接嵌入现有项目。目前已有2203人学习下载。资源按示例与源码模块化组织既可直接基于示例改造接入也可对比Vue响应式与原生DOM两种写法从而深入理解实时通信、浏览器录音和前端音频处理的关键细节。1. 科大讯飞 Vue.js 语音听写流式实现一条音频从麦克风到字幕的实时链路做会议字幕或客服质检工具时第一次接触科大讯飞 Vue.js 语音听写流式实现很容易把它理解成一套拿来即用的语音转文字组件。拆开看它其实是一条贯穿三层的实时链路浏览器麦克风采集、WebSocket 长连接上的音频帧、以及按句返回的中间识别结果。目标只有一个——用户一边说话文本一边冒出来而不是等整段录音结束再回传。之所以强调 Vue是因为前端这边真正花时间的不是界面而是连接建立时机、结束帧发送时机和页面销毁时的资源释放。适合想在 1 到 2 周内把实时听写能力接进产品而不是从零研究语音算法或训练声学模型的团队。整条链路里最容易被卡住的三个点分别是鉴权 URL 的签名、PCM 音频格式的匹配、以及服务端中间结果和最终结果的状态区分。Vue 本身只负责把这些点串成可控的用户体验。下面按我实际接项目的顺序来讲先打通协议再处理录音最后聊状态机和那些不踩一遍很难发现的坑。2. 流式接口的钥匙WebSocket 鉴权 URL 和帧协议2.1 为什么实时听写必须用 WebSocket而不是把整段音频丢给 HTTP离线文件转写走的是“上传完整文件等待异步回调”的 REST 流程适合录音文件事后转文字。实时听写的体验要求是边说边出服务端必须在音频到达过程中持续回传增量结果客户端和服务端之间需要一条长时间保持的双向通道。讯飞语音听写流式版对外开放的端点是一个 WebSocket 地址路径通常落在 /v2/iat 这一类接口上整条链路就是建立在 wss 协议之上的。这里最常见的误解是以为直接new WebSocket(wss://iat-api.xfyun.cn/v2/iat)就能连上。实际服务端会先校验地址里的签名参数校验不过就直接断开或拒绝握手。浏览器端的 WebSocket API 不允许自定义 Authorization 请求头所以这类语音服务普遍把签名放进 URL 的 query 参数里。理解了这一点后续的调错思路就不会跑偏凡是握手阶段失败优先怀疑签名而不是怀疑音频格式。签名方案本身属于 HMAC-SHA256 加 base64 的组合很多做过云服务接口对接的开发者会觉得熟悉。它把 host、date、request-line 拼成一个待签名字符串用 APISecret 作为密钥计算签名再把签名拼进 authorization 字符串最后整体 base64 编码后放入 URL。只要把这条生成链路独立封装好调试时能省掉大量反复试错的时间。2.2 鉴权 URL 的生成host、date、request-line 三件套我习惯用一个独立模块来生成鉴权 URL不把它散落在 Vue 组件里。这样不管是调试还是将来换框架都能复用同一套逻辑。下面是一个用 CryptoJS 实现的生成函数适合在浏览器端或 Node 环境运行import CryptoJS from crypto-js; function buildAuthUrl(apiKey, apiSecret) { const host iat-api.xfyun.cn; const path /v2/iat; const date new Date().toUTCString(); const requestLine GET ${path} HTTP/1.1; // 待签名字符串三行之间是换行符不能多也不能少 const signatureOrigin host: ${host}\ndate: ${date}\nrequest-line: ${requestLine}; // 用 APISecret 作为 HMAC 密钥结果转 base64 const signature CryptoJS.HmacSHA256(signatureOrigin, apiSecret).toString(CryptoJS.enc.Base64); // 按约定拼出 authorization 字符串 const authorization api_key${apiKey}, algorithmhmac-sha256, headershost date request-line, signature${signature}; // 对 authorization 整体再做一次 base64 const authorizationBase64 CryptoJS.enc.Base64.stringify( CryptoJS.enc.Utf8.parse(authorization) ); const params new URLSearchParams({ authorization: authorizationBase64, date: date, host: host }); return wss://${host}${path}?${params.toString()}; }这段代码里值得留意的有三处。第一signatureOrigin中的换行必须是字符串里的\nhost 这一行不能带https://前缀更不能顺手加端口除非对应版本文档明确要求带端口。第二date用的是toUTCString()也就是 RFC 1123 格式签名里的 date 和 URL 参数里的 date 必须是同一个值如果生成 URL 之后页面停留太久这个签名会过期。第三URLSearchParams 会自动处理 base64 中的、/、字符浏览器地址栏里显示成%2B是正常现象服务端会先解码再验签不需要手动反转义。2.3 音频帧与 text 消息二进制的节奏控制鉴权 URL 拿到之后连接建立只是第一步。协议层的数据交互分成三个阶段先发一个 JSON 文本帧携带应用参数然后持续发送二进制音频帧最后再发一个 JSON 文本帧通知服务端“音频结束”。三个阶段的顺序一旦颠倒服务端会直接判定协议错误。第一阶段的第一帧文本消息大致是这样ws.onopen () { const firstFrame { common: { app_id: APP_ID }, business: { language: zh_cn, domain: iat, accent: mandarin, vad_eos: 10000, // 静默多久认为一句话结束单位毫秒 dwa: wpgs // 开启后结果里会带更细的边界字段按需使用 }, data: { status: 0, // 0 表示还有后续数据 format: audio/L16;rate16000, encoding: raw, audio: // 文本帧里 audio 为空 } }; ws.send(JSON.stringify(firstFrame)); };这里的 business 参数决定了识别场景。vad_eos控制静默断句的时长会议场景需要调大短句对话场景可以调小。format里的audio/L16;rate16000表示 16kHz 采样率、16bit 位深的裸 PCM 数据encoding: raw表示不压缩。如果麦克风采集到的原始数据不是这个格式必须在发送前完成转换后面第三章会专门讲。第二阶段是二进制帧的持续发送。按照 16kHz、16bit、单声道计算40ms 音频恰好是 1280 字节这也是大多数实现里单帧的推荐长度。客户端按这个长度把音频切块依次通过ws.send(buffer)发送期间不要再插入其他 JSON 文本帧。第三阶段是发送结束信号逻辑上同样是一个 JSON 文本帧但data.status变成 2表示“我这边没有更多音频了”ws.send(JSON.stringify({ data: { status: 2 // 2 表示客户端发送结束 } }));注意这个status是客户端发送方向的状态和服务端返回结果里的status含义完全不同。服务端返回的data.status也有 0 和 1/2但表达的是“这段内容是中间结果还是最终结果”。这两个词汇很容易在代码里混用我一般会在变量名上强制区分比如sendStatus和respStatus。3. 在 Vue 里接麦克风AudioWorklet 降采样与 PCM 帧的发送节奏3.1 选录音链路MediaRecorder 的免配置陷阱很多开发者第一次做浏览器录音第一反应是用MediaRecorder因为它 API 简单能直接拿到录音文件。但这个选择在流式听写场景里会带来一个绕不过去的坑MediaRecorder默认输出的是 WebM/Opus 格式而讯飞流式听写接口要求的是裸 PCM 或特定编码格式。如果把 WebM 的二进制数据直接放到ws.send里服务端会返回音频格式类错误而且这个错误往往不是立刻返回而是等发送到一定量才报出来排查成本很高。还有一条路是把MediaRecorder得到的 Opus 数据用decodeAudioData解码后再发给服务端。问题在于decodeAudioData通常需要等拿到完整的一段音频才能解码这实际上把流式变成了准实时中间结果的延迟会明显变大。所以我的选择是用 Web Audio API 取原始 PCM先通过getUserMedia拿麦克风流再创建AudioContext用AudioWorklet做降采样和格式转换。AudioWorklet 相比旧的ScriptProcessorNode延迟更低运行在独立音频线程里不会因为主线程渲染卡顿而丢数据。选择这条路的原因还有一个工程上的考量AudioWorklet 的处理器可以在音频线程里维护一个环形缓冲精确地按 1280 字节切帧。这样主线程只需要负责把切好的帧转发给 WebSocket职责单一出了问题也容易定位是采集环节还是传输环节。3.2 AudioWorklet 里的降采样和 PCM 拼帧浏览器麦克风采集的默认采样率通常是 48000而接口要的是 16000。降采样我一般用简单的线性插值实现虽然不如高阶滤波器精度高但对于语音识别场景足够了CPU 占用也更低。下面是 Processor 的核心实现class DownsamplerProcessor extends AudioWorkletProcessor { constructor() { super(); this.buffer new Float32Array(0); this.SRC_RATE sampleRate; // AudioWorklet 运行时的采样率 this.TARGET_RATE 16000; this.chunkSamples 640; // 1280 字节 / 2 字节每样本 } process(inputs) { const input inputs[0] inputs[0][0]; if (!input) return true; const ratio this.SRC_RATE / this.TARGET_RATE; const outLength Math.floor(input.length / ratio); const downsampled new Float32Array(outLength); // 线性插值降采样ratio 一般是 3 for (let i 0; i outLength; i) { const srcIndex i * ratio; const p Math.floor(srcIndex); const q Math.min(p 1, input.length - 1); const frac srcIndex - p; downsampled[i] input[p] * (1 - frac) input[q] * frac; } // 拼接上一次剩余的数据 const merged new Float32Array(this.buffer.length downsampled.length); merged.set(this.buffer, 0); merged.set(downsampled, this.buffer.length); this.buffer merged; // 每凑满 640 个样本转成 Int16 后发给主线程 while (this.buffer.length this.chunkSamples) { const chunk this.buffer.slice(0, this.chunkSamples); const pcm new Int16Array(this.chunkSamples); for (let i 0; i this.chunkSamples; i) { const v chunk[i] * 32767; pcm[i] Math.max(-32768, Math.min(32767, v)); } this.port.postMessage(pcm.buffer, [pcm.buffer]); this.buffer this.buffer.slice(this.chunkSamples); } return true; } } registerProcessor(downsampler, DownsamplerProcessor);这个处理器里有几个细节决定了它能不能直接用于生产。降采样时不能简单跳点采样否则高频成分会产生混叠导致识别率下降线性插值虽然简单但在 16kHz 场景下比跳点采样可靠得多。转 Int16 时要注意浮点 PCM 的取值范围是 -1 到 1必须先乘以 32767 再做截断否则会出现音量极小或削波破音的问题。postMessage的第二个参数是转移列表把pcm.buffer转移给主线程后音频线程里这块内存就不再可写能减少一次拷贝但同时意味着不能在同一段代码里复用这个 buffer。主线程拿到这个ArrayBuffer后直接塞进 WebSocket 就行。发送节奏上有一点需要克制不要因为 AudioWorklet 产生帧的速度稳定就用定时器机械地每隔 40ms 发一帧。更稳妥的做法是在主线程维护一个队列收到 processor 的消息就入队WebSocket 处于 open 状态时就发送。如果ws.bufferedAmount持续增大说明网络或服务端处理不过来此时应该暂停发送并考虑降级提示而不是让帧继续堆积。3.3 Vue 组件生命周期与麦克风资源的绑定Vue 组件里需要处理的不只是音频转发还有麦克风权限、组件卸载和路由切换。实际操作中我会把一个典型的听写组件拆成 start、stop、dispose 三个方法其中 dispose 必须在 onUnmounted 里被无条件调用script setup langts import { ref, onMounted, onUnmounted } from vue; const text ref(); let ws: WebSocket | null null; let audioContext: AudioContext | null null; let stream: MediaStream | null null; let workerNode: AudioWorkletNode | null null; async function start() { // 先申请麦克风权限 stream await navigator.mediaDevices.getUserMedia({ audio: true }); // 创建 AudioContext并加载 AudioWorklet 模块 audioContext new AudioContext({ sampleRate: 48000 }); await audioContext.audioWorklet.addModule(/downsampler.js); workerNode new AudioWorkletNode(audioContext, downsampler); workerNode.port.onmessage (event) { if (ws ws.readyState WebSocket.OPEN) { ws.send(event.data); } }; const source audioContext.createMediaStreamSource(stream); source.connect(workerNode); workerNode.connect(audioContext.destination); // 这里再建立 WebSocket 并发送第一帧 } function stop() { // 发送 status: 2 结束帧等服务端最终结果 } function dispose() { stream?.getTracks().forEach((track) track.stop()); audioContext?.close(); ws?.close(); workerNode?.disconnect(); } onUnmounted(() { dispose(); }); /script这段组件代码里的顺序是经过几次调整后定下来的。必须先getUserMedia再创建 AudioContext因为浏览器要求音频上下文要在用户手势之后才能启动。AudioWorkletNode连接audioContext.destination是为了让音频流真正跑起来如果不连接输出节点部分浏览器会把整个音频图挂起process 不会触发。另外addModule的路径不能写错这个模块加载是异步的失败时要在 catch 里给用户提示否则会出现点击录音后完全无响应的“假死”状态。麦克风权限被拒绝是另一个需要单独处理的场景。getUserMedia抛出的错误类型里NotAllowedError和NotFoundError要给不同的 UI 反馈前者提示去浏览器设置里打开授权后者提示检查设备。如果不区分用户会在一个模糊的“录音失败”提示里反复点击体验很差。4. 听写状态机与结果渲染从中间结果到稳定的实时字幕4.1 状态机设计从 idle 到 closed 的清晰流转实时听写和普通请求最大的区别是它有多个阶段而且阶段之间并不总是线性推进。网络断开、用户中途暂停、静默超时都会让状态发生跳变。如果不做状态机代码里会出现大量互相矛盾的布尔值比如“正在录音”和“已断开”同时为真导致按钮状态混乱。我用的状态集合是这样一组type ListenState | idle // 初始状态什么都没做 | connecting // WebSocket 握手阶段 | ready // 连接已建立可以开始录音 | recording // 正在录音并发送音频 | finalizing // 已发送结束帧等待最终结果 | closed; // 会话结束资源已释放connecting和ready分开是必要的因为 WebSocket 的onopen只是一个信号第一帧文本发送完成后服务端可能还需要几百毫秒准备识别引擎。如果用户在这个窗口内说话音频帧会被丢弃。我一般以服务端返回的第一个响应作为ready的进入条件而不是前端onopen。finalizing这个状态特别容易漏掉。发送完data.status: 2之后服务端不会立刻关闭连接而是要先吐完最后一句中间结果再返回一个代表结束的响应。如果前端在发送结束帧后马上把按钮恢复成“录音”用户就会看到字幕还在滚动但已经无法继续输入。所以 stop 操作之后必须停留在finalizing直到收到服务端结束响应才置为closed。状态机的实现不一定要引入重量级状态库Vue 3 里用一个refListenState加几个计算属性就能撑住。关键是所有状态转换只能通过明确的函数进行比如start()、stop()、handleWsClose()不要在onmessage回调里随手改状态否则后续排查时根本想不起状态是在哪里被改坏的。4.2 结果解析respStatus 为 0 与 1/2 的拼接规则服务端推送过来的每一帧 JSON 里都带有data.status这个respStatus决定了解析策略。为了不搞混我会在代码注释里把客户端发送方向和接收方向的状态分开说明下面这个函数专门负责从一帧响应里提取文本function parseIatResult(payload: any): string { const resultStr payload?.data?.result; if (!resultStr) return ; const result JSON.parse(resultStr); // ws 是词序列cw 是候选字数组通常取第一个候选 return result.ws .map((wsItem: any) wsItem.cw.map((cwItem: any) cwItem.w).join() ) .join(); }中间结果和最终结果的拼接逻辑是完全不同的。当respStatus 0返回的是当前识别区间内的增量文本但它不代表最终结果后续可能会修正。UI 上的正确姿势是创建一个“临时字幕区”每次拿到新的中间结果就整体替换这个区域而不是追加。等到respStatus为 1 或 2这个句子基本定稿再把它从临时区移入正式历史记录区。还有一个容易被忽略的字段是data.result.sn它代表句子编号。当一次会议里有长句、有短句服务端可能交错返回不同句子的结果。只按到达顺序拼接会出现词序错乱。我通常用一个 Map 按sn暂存等同一个 sn 的状态变为最终后再按序合并。data.result.ls为 true 时表示这已经是最后一句可以触发最终的展示逻辑。4.3 超时、重连与“正在思考”的 UI 降级WebSocket 在移动端并不稳定。手机锁屏、切换 Wi-Fi 或只是从后台切回前台连接都可能在毫秒级被掐断而前端却要等onclose触发才能感知。更麻烦的是重连不能简单地拿旧 URL 再new WebSocket一次因为鉴权 URL 里的 date 已经过期而且服务端那边的识别会话已经结束重连成功也不能接着旧状态继续识别。我一般把重连策略控制在会话层面而不是帧层面。如果断开发生在录音刚开始、还没有任何最终结果产出时可以自动重连并让用户重新说一遍一旦已经有内容进入正式记录区我不会尝试自动恢复而是把已识别文本保存下来提示用户手动继续下一段。自动重连的鉴权 URL 必须重新生成不能复用之前的 URL否则大概率会被签名校验拒掉。UI 降级同样重要。服务端在处理一段较长的静默时中间结果会停顿几秒这时用户不知道是网络断了还是识别器在思考。我会在超过 2 秒没有新中间结果时显示“正在识别中”的动画超过 5 秒则提示“检测不到声音或网络延迟”把控制权交还给用户。这类反馈看似是小事但在客服质检这样的真实场景里直接决定了这套功能会不会被用户关掉。5. 避坑篇讯飞流式听写在真实环境里的 5 个翻车点5.1 现象wss 握手一直 401/1006签名照抄还是失败这个坑几乎每个接流式接口的开发者都会遇到一次。表面现象是WebSocket的onerror触发浏览器控制台报 1006或者服务端返回带错误码的关闭帧。原因通常出在三个地方待签名字符串里的 host 写错比如带了https://前缀或者多了一个端口号date 使用了本地时间格式而不是toUTCString()还有的组件库会对 URL 参数中的号自动解码导致传给WebSocket的地址已经不是原始签名串。解决方法是把签名逻辑做成独立函数先用 Node 脚本在本地生成一条完整的 wss 地址粘贴到在线 WebSocket 测试工具里验证能连上再接进 Vue。这一步能过滤掉环境相关的问题。另外调试时不要反复刷新页面却不关闭旧连接讯飞对单账号并发连接数有限制旧连接还活着新连接可能直接被拒绝表现为间歇性连接失败。5.2 现象服务端返回 10907/10908音频参数看着没问题这类错误码属于音频数据异常。连接正常第一帧也发了但服务端在解音频时发现数据不合法。常见原因不是参数写错而是前端发送的字节流和参数不匹配。比较典型的是 Float32 数据没有正确转成 Int16直接以Float32Array的底层字节发送服务端解析出来全是噪声或空数据。还有的麦克风设备默认采集立体声前端没有做单声道合并导致采样率对不上。解决方法是先在 AudioWorklet 里统一完成单声道转换和 Int16 转换再用wc -c之类的工具检查生成文件的字节数。例如 10 秒 16kHz 单声道 PCM 文件应该是 10 × 16000 × 2 320000 字节。如果字节数对不上问题就在采集端不要急着改服务端参数。5.3 现象字幕一直出中间结果最后一句永远不落盘中间结果持续滚动但最后一句始终没有变成正式结果用户点停止后字幕定格在半句上。原因几乎都是结束帧没有发对。有的实现把结束帧写成了{ data: { status: 2 } }但因为 JSON 字段名拼错或者直接发送了空对象服务端一直等不到结束信号。另一种情况是收到结束帧后前端立刻调用了ws.close()服务端还没来得及返回最终结果就被掐断了。解决方法是把结束流程固定成两步先发送结束帧然后等待服务端返回respStatus为 2 的最终响应完整处理完这帧之后再由前端关闭连接。在这之间可以加一个超时兜底比如 3 秒内没收到最终响应就提示用户主动结束。需要反复确认的一点是结束帧里的status是发送方向的状态值别把服务端返回状态里的 2 当成客户端要发的状态两边语义完全不同。5.4 现象切路由后麦克风还在闪页面凉了还在录音Vue 单页应用里切换路由后组件实例会被卸载但如果卸载时没有释放资源浏览器地址栏的麦克风图标会一直亮甚至页面已经被销毁了录音还在继续。原因是在onUnmounted里只关了 WebSocket没有停掉MediaStreamTrack也没有关闭AudioContext。解决方法是把资源的释放集中到一个dispose函数里按固定顺序执行先停止所有stream.getTracks()再断开AudioWorkletNode连接最后关闭 AudioContext 和 WebSocket。这里有个细节stream.getTracks()必须在关闭 AudioContext 之前调用有些浏览器如果先关闭了音频上下文track 的stop会变得不彻底设备指示灯依然亮着。5.5 现象静默几秒就被切断长停顿之后第一句不识别会议场景里说话人经常停顿三五秒每次都触发断句下一句话开头半个字被吞掉。原因在于business里的vad_eos参数默认值比较小服务端认为静默超过阈值就结束当前句开始返回最终结果并等待下一句。如果前端没有为这种“人为断句”做好准备下一次说话时会发现连接还在但音频帧没有被正确接上。解决方法是在业务参数里把vad_eos调大会议纪要场景我一般调到 10000 到 15000 毫秒短句对话场景用 3000 到 5000 毫秒。同时客户端的停止按钮逻辑要跟vad_eos配合用户手动点停止时发送结束帧静默超过vad_eos时让服务端自动断两边不要重复触发。若发现长停顿后第一句丢字还要检查是不是前端在暂停期间没有继续发送静音帧导致音频流出现空档。6. 上线前的最小链路验证先打通接口再优化体验很多项目出问题不是因为代码写错而是因为调试时把所有环节混在一起。我的习惯是先用一条最小链路验证接口完全不碰 UI。具体做法是用 ffmpeg 准备一段固定时长的 16kHz 单声道 PCM 文件再用 Node 脚本直连 WebSocket把鉴权 URL 生成、音频发送、结束帧发送和结果接收跑完。这一步通了再去 Vue 里接麦克风排查范围会小很多。# 从任意音频文件生成 16kHz 单声道 PCM 测试文件 ffmpeg -i input.wav -ar 16000 -ac 1 -f s16le test_16k.pcm # 查看文件字节数5 秒应为 5 * 16000 * 2 160000 字节 ls -l test_16k.pcm验证脚本里我会把每个关键节点的时间戳打出来连接建立时间、第一帧发送时间、第一条中间结果返回时间、最终结果返回时间。首字返回时间基准上应该控制在发送后 300 到 1000 毫秒内如果超过 2 秒说明问题可能在网络环境或接口地区节点上。这些时间和音频文件本身无关纯粹是链路健康度的体检。验证通过后再回到 Vue 端做体验优化这时才会去调 AudioWorklet 的帧大小、WebSocket 发送队列长度、以及状态机的重连阈值。帧大小我一般保持在 40ms也就是 1280 字节这个值在识别延迟和服务端负载之间比较均衡。如果网络环境差可以放宽到 60ms 一帧但首字延迟会增加需要权衡。我踩过的最大一次翻车就是把所有精力放在组件样式上结果接口还没验证。后来再接手这类项目我都会先让 Node 脚本跑通一整段会议录音确认最终结果完整无误才允许自己碰 Vue 代码。这个习惯保证了我每次接入流式语音服务都能在一两天内看到真实字幕滚动起来。希望帮到你。本文还有配套的精品资源点击获取

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

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

免费获取报价 →
↑