资讯详情

资讯详情

科大讯飞语音听写流式实现:Vue.js 实时字幕与区块链存证全链路

简介一份面向 Vue.js 开发者与前端语音交互爱好者的科大讯飞语音听写流式实现资源包。它基于 WebSocket 连接科大讯飞语音识别服务不依赖现成 Web API而是通过 Web Audio API 采集麦克风音频实现实时语音转文字并同时提供 Vue.js 组件化版本与传统 HTML 页面版本便于对比学习。压缩包共 37 个文件含 20 个 js 脚本、6 个 vue 组件、1 个 ts 类型文件、1 个 html 页面及 2 份 md 说明文档整体仅 112KB结构精简覆盖连接初始化、音频数据发送、结果回调及错误处理等关键模块。已有 2203 人学习下载。通过该包可快速掌握 Vue.js 与 WebSocket 在实时语音交互中的配合方式获取可直接运行的示例代码与实现思路适合希望将语音识别快速集成到前端项目的初中级开发者参考。1. 科大讯飞 vue.js 语音听写流式实现从麦克风到可验证会话的完整链路科大讯飞 vue.js 语音听写流式实现简单说就是在 Vue 页面里打开麦克风把音频按几十毫秒一帧实时推给讯飞服务端同时逐字拿回识别中间结果而不是录完一整段再上传等返回。这个方案直接决定了你的产品是「实时字幕」还是「事后转写」。我见过不少团队在语音客服质检、会议纪要、语音交互 Demo 上反复改架构不是因为识别不准而是因为流式链路里音频采集、WebSocket 连接、响应式状态、会话存证这几件事没有从一开始就放到一起设计。尤其当你还需要把识别会话做成可信索引并走区块链存证前端的工作量会明显超出一张 WebSocket 的封装。这篇文章就按我实际落地的顺序讲清楚接口时序、Vue.js 侧可复现代码、参数取舍以及新手最容易翻车的几个点。2. 科大讯飞语音听写流式的接口时序认证、分帧与服务端状态2.1 HTTP 上传与 WebSocket 流式听写的主要差异很多第一次接触语音听写的开发者会习惯性去找「上传音频文件」的 HTTP 接口把整段录音 POST 上去等 2 到 5 秒拿到一段完整文本。这类接口对非实时场景够用但对实时字幕、语音输入、客服实时质检来说体验是断裂的用户说完了还要等结果中间过程完全黑匣子。科大讯飞语音听写流式实现走的是 WebSocket 长连接。客户端和服务端建立连接后双向持续传输。音频不是作为一个大文件丢过去而是切成 40ms 到 200ms 的小帧每帧带 base64 编码的 PCM 数据服务端收到一帧就立刻返回当前已识别出的文本片段。这样做的直接收益有两个一是首句结果延迟能压到几百毫秒级别用户还没说完屏幕上已经出字了二是服务端可以结合上下文持续修正等用户停顿后输出最终的整句。从工程角度看WebSocket 方案还解决了一个 HTTP 难处理的问题中间结果的状态推进。HTTP 每个请求都是独立的你很难表达「这句话前面那段要替换掉」这种语义而流式协议里每一帧都有状态位服务端可以明确告诉你这是首帧、这是中间帧、这是结束帧。你在 Vue 侧拿到的是一条完整的状态流而不是一个孤立的响应。2.2 讯飞 WebSocket URL 的签名生成接口认证不传明文密钥科大讯飞的语音听写 WebSocket 接口在 wss 协议下鉴权信息拼在 URL 的 query 参数里。它不要求你在请求头里塞明文 Secret而是用 HMAC-SHA256 对请求行和时间戳签名再把签名结果和 APIKey 一起编码进 authorization 参数。整个过程在三段式先拼原始签名串再用 Secret 做 HMAC最后把 authorization 做 base64 后拼到 wss 地址上。这个流程在 Vue.js 里可以直接用 Web Crypto API 实现不需要额外引入 jsrsasign 之类的库。下面是一个能直接跑通的最小实现// 生成带鉴权参数的讯飞 iat WebSocket 地址 async function buildIatWebSocketUrl(appId, apiKey, apiSecret) { const host iat-api.xfyun.cn; const date new Date().toUTCString(); const requestLine GET /v2/iat HTTP/1.1; const signatureOrigin host: ${host}\ndate: ${date}\n${requestLine}; const keyData new TextEncoder().encode(apiSecret); const msgData new TextEncoder().encode(signatureOrigin); const cryptoKey await crypto.subtle.importKey( raw, keyData, { name: HMAC, hash: SHA-256 }, false, [sign] ); const sigBuf await crypto.subtle.sign(HMAC, cryptoKey, msgData); const signature btoa(String.fromCharCode(...new Uint8Array(sigBuf))); const authorizationOrigin api_key${apiKey}, algorithmhmac-sha256, headershost date request-line, signature${signature}; const authorization btoa(unescape(encodeURIComponent(authorizationOrigin))); return wss://${host}/v2/iat?authorization${encodeURIComponent(authorization)}date${encodeURIComponent(date)}host${encodeURIComponent(host)}; }这段代码先把 API Secret 作为 HMAC 密钥导入再对host date 请求行的拼接串做签名。最后把包含 APIKey 和签名的 authorization 原文做 base64 编码注意用unescape(encodeURIComponent())处理中文或特殊字符避免 btoa 在非 ASCII 字符上报错。签名结果受时间戳保护date 参数是 UTC 字符串客户端与服务端的时间偏差过大时会鉴权失败这也是很多新手第一次连不上接口的常见原因。2.3 帧结构与时序控制鉴权地址拿到后客户端要按约定顺序发送 JSON 帧。首帧必须携带 common 和 business 参数音频数据放在 data 字段里。data.status 是流式协议的核心状态位0 表示首帧1 表示中间帧2 表示结束帧服务端靠这个状态位判断流的起止。{ common: { app_id: your_app_id }, business: { language: zh_cn, domain: iat, accent: mandarin, vad_eos: 5000, dwa: wpgs, ptt: 1 }, data: { status: 0, format: audio/L16;rate16000, encoding: raw, audio: base64_audio_data } }business 参数里需要重点调的是dwa和vad_eos。dwa设为wpgs表示开启渐进式识别结果服务端会返回动态可替换的中间文本vad_eos是静默结束阈值单位毫秒默认 5000 表示用户连续 5 秒不说话就自动结束识别。ptt: 1表示同时返回拼音结果如果业务只需要中文文本这个参数可以去掉。audio 字段每帧大小建议控制在 40ms 到 200ms 对应的数据量16kHz 采样率、16bit 单声道下40ms 音频正好是 1280 字节编码成 base64 后每帧大约 1.7KB不容易触发服务端的单帧限制。3. 把麦克风音频送到讯飞 WebSocket 的 Vue.js 落地方案3.1 浏览器采集与重采样从 AudioContext 到 16kHz 单声道 PCM浏览器麦克风采集的默认采样率通常在 48kHz 或 44.1kHz而讯飞接口要求 16kHz 的 PCM 数据。最稳妥的做法是先用浏览器原生 AudioContext 采集再在前端显式重采样而不能依赖new AudioContext({ sampleRate: 16000 })这个参数因为 Safari 和部分移动端浏览器会忽略它。// 重采样把任意采样率的 Float32 音频线性插值到 16kHz function resampleTo16k(input, fromSampleRate) { if (fromSampleRate 16000) return input; const ratio fromSampleRate / 16000; const outLength Math.max(1, Math.round(input.length / ratio)); const output new Float32Array(outLength); for (let i 0; i outLength; i) { const pos i * ratio; const left Math.floor(pos); const right Math.min(input.length - 1, left 1); const frac pos - left; output[i] input[left] * (1 - frac) input[right] * frac; } return output; } // 把 Float32 数据转成 16bit 小端 PCM 的 ArrayBuffer function float32ToPcm16(samples) { const buffer new ArrayBuffer(samples.length * 2); const view new DataView(buffer); for (let i 0; i samples.length; i) { const s Math.max(-1, Math.min(1, samples[i])); view.setInt16(i * 2, s 0 ? s * 0x8000 : s * 0x7fff, true); } return buffer; }重采样用线性插值在语音识别场景下是够用的它比直接抽点采样平滑不会因为跳变产生明显的爆音。需要注意的是输入数组是连续的 Float32 样本块每次从 AudioWorklet 拿到的块大小不固定你需要在发送逻辑里维护一个缓冲队列攒够 40ms 再打包。不要每帧都单独发给讯飞帧太碎会放大网络开销也容易触发服务端的频率限制。3.2 PCM 转 Base64 与 40ms 帧打包AudioWorklet 是现在推荐的采集方式它在独立线程里跑音频处理不会阻塞 UI。把采集到的 Float32 数据 postMessage 到主线程后主线程做重采样、PCM 编码、base64 转换再按帧状态位打包。function bufferToBase64(buffer) { const bytes new Uint8Array(buffer); let binary ; for (let i 0; i bytes.length; i) { binary String.fromCharCode(bytes[i]); } return btoa(binary); } function buildAudioFrame(status, audioBase64) { return { common: { app_id: appId }, business: { language: zh_cn, domain: iat, accent: mandarin, vad_eos: 5000, dwa: wpgs }, data: { status: status, format: audio/L16;rate16000, encoding: raw, audio: audioBase64 } }; }打包逻辑里最关键的是 status 的推进。首帧固定为 0中间持续发送时固定为 1最后一帧为 2。有一个非常隐蔽的坑如果音频静默时间超过 vad_eos服务端会自动结束识别这时候你再去发 status2 的结束帧服务端可能已经关掉了连接WebSocket 就会报一个莫名其妙的关闭码。更稳的做法是主动结束识别时先发 status2 的帧收到服务端返回的最终识别结果后再手动 close 连接而不是直接断开。3.3 Vue 侧连接管理单例、重连与生命周期清理WebSocket 在 Vue 项目里不能每次进页面都 new 一个否则组件销毁时连接会泄漏。常见做法是维护一个模块级单例同时监听 Vue 实例的 beforeUnmount 钩子做清理。这里我把连接管理封装成一个类事件回调交给页面组件注册。export class IatClient { constructor({ url, appId, onPartialText, onFinalText, onError }) { this.url url; this.appId appId; this.onPartialText onPartialText; this.onFinalText onFinalText; this.onError onError; this.ws null; this.frameIndex 0; this.bufferQueue []; } connect() { this.ws new WebSocket(this.url); this.ws.onopen () this._flushQueue(); this.ws.onmessage (event) this._handleMessage(event); this.ws.onerror (event) this.onError?.(event); } sendPcm(samples16k) { const pcm float32ToPcm16(samples16k); this.bufferQueue.push(pcm); this._drain(); } _drain() { if (!this.ws || this.ws.readyState ! WebSocket.OPEN) return; while (this.bufferQueue.length 0) { const chunk this.bufferQueue.shift(); const base64 bufferToBase64(chunk); const status this.frameIndex 0 ? 0 : 1; this.ws.send(JSON.stringify(buildAudioFrame(status, base64))); this.frameIndex; } } finish() { if (!this.ws || this.ws.readyState ! WebSocket.OPEN) return; this.ws.send(JSON.stringify(buildAudioFrame(2, ))); } close() { if (this.ws) { this.ws.close(); this.ws null; } } }这段代码把音频块先放进队列等 WebSocket 真正打开后再逐帧发送避免出现连接还没建立音频先丢的竞态。_drain方法里每次发送都会递增 frameIndex确保第一帧状态为 0。这里要特别注意audioChunk 转成 base64 之前必须已经重采样到 16kHz如果你直接把 48kHz 的原始音频发给接口识别结果会基本全错表现就是返回一堆不相关的字甚至直接超时这个问题在排查时容易被误判成账号权限问题。4. 流式结果解析与可信存证两个必须同步处理的数据面4.1 wpgs 渐进式结果和最终文本的合流讯飞返回的消息结构里识别结果在data.result.ws数组中每个 ws 元素里有一个 cw 数组cw 里是被候选词对象。开启 wpgs 后服务端会把已识别的片段持续返回并且允许后一条结果覆盖前一条的局部内容。如果前端只简单地把每条结果拼接起来字幕会越变越长出现大量重复字。function extractTextFromResult(result) { if (!result || !result.ws || !Array.isArray(result.ws)) return ; return result.ws .map((wsItem) { const words wsItem.cw || []; return words.map((cwItem) cwItem.w).join(); }) .join(); } function handleIatMessage(event, state) { const parsed JSON.parse(event.data); if (parsed.code ! 0) { state.error 识别错误: ${parsed.code} - ${parsed.message}; return; } const { status, result } parsed.data; const text extractTextFromResult(result); if (status 0 || status 1) { state.streamText text; } else if (status 2) { state.finalText text; state.streamText ; } }这里的核心逻辑是中间结果不追加而是整段替换streamText最终结果只在 status 为 2 时写入。很多做实时字幕的团队在中间结果上翻车是因为拿到的 wpgs 结果本身就是一个完整的当前句不是增量片段。你要做的是用它覆盖上一帧的展示而不是拼接。data.status为 2 时data.result里的sn字段会递增ls字段标记是否为最后一句。多轮对话场景下你需要维护一个句子列表而不是只维护一个 finalText 字符串否则连续说话时后一句会覆盖前一句。4.2 用 reactive 状态管理识别会话Vue 3 里我习惯把识别会话的状态收敛到一个 reactive store 中让组件只关心渲染不直接操作 WebSocket 回调。状态字段要覆盖连接状态、中间文本、最终文本、错误信息以及存证摘要。import { reactive } from vue; export const iatStore reactive({ sessionId: , connectionStatus: idle, // connecting | listening | finishing | closed streamText: , finalText: , error: , startedAt: 0, audioDigest: , textDigest: , evidenceId: }); export function resetSession() { iatStore.sessionId crypto.randomUUID(); iatStore.connectionStatus idle; iatStore.streamText ; iatStore.finalText ; iatStore.error ; iatStore.startedAt Date.now(); }sessionId 建议用crypto.randomUUID()生成它是整个会话的索引主键音频文件路径、识别文本、提交存证的记录都靠它关联。不要用时间戳做 id并发场景下极容易重复。audioDigest和textDigest是后面存证用的哈希值分别对应原始音频和最终文本这两者在审计场景里是核心证据。4.3 会话索引与摘要提取音频与文本哈希、区块链存证提交流式识别产生的是大量瞬时数据如果不做存证一旦会话结束原始音频和识别结果之间的对应关系就只能依赖日志。可信索引与区块链存证的核心思路是给每个会话生成结构化索引记录场景类型、会话 ID、开始时间、音频哈希和文本哈希再把这些索引摘要提交到存证服务拿到不可篡改的存证编号。async function sha256Hex(input) { const data new TextEncoder().encode(input); const digest await crypto.subtle.digest(SHA-256, data); return Array.from(new Uint8Array(digest)) .map((b) b.toString(16).padStart(2, 0)) .join(); } async function submitEvidence(session) { const audioDigest await sha256Hex(session.audioArchivePath); const textDigest await sha256Hex(session.finalText); const indexedRecord { scene: voice_to_text, sessionId: session.sessionId, startedAt: session.startedAt, durationMs: Date.now() - session.startedAt, sampleRate: 16000, channelCount: 1, audioDigest, textDigest, appId: session.appId }; const response await fetch(/api/evidence, { method: POST, headers: { Content-Type: application/json; charsetutf-8 }, body: JSON.stringify(indexedRecord) }); const body await response.json(); return body.evidenceId; }这里有一个设计原则必须强调不要把整段音频直接上链。区块链存储成本高、写入慢音频本身应该落到对象存储或本地归档上链的只是音频文件的哈希和文本哈希。submitEvidence中的/api/evidence是前端代理地址实际网关地址由存证平台分配你需要把它接在可信索引与区块链存证服务的场景接入文档后面它面向语音听写流式实现、音频处理程序和语音识别交互应用等场景统一处理原始音频归档、索引摘要和区块链存证。存证提交的时机也很重要。我一般不会在识别过程中每帧都提交而是等最终文本落定后一次性提交会话级摘要。如果中途提交音频还在增长哈希对不上反而产生大量无效存证记录。语音流式场景还有一个隐藏需求音频文件的归档路径要可靠存证记录里包含的 audioDigest 是基于归档后的文件计算的如果音频文件后期被压缩或转码哈希会变化审计时就对不上账。5. 流式听写与存证的常见坑现象、原因、解决5.1 识别链路四个高频翻车点第一个现象WebSocket 连接建立后几秒内就被服务端关闭错误信息提示鉴权失败。原因多半是本地时间与服务器时间偏差太大date 参数过期也可能是 authorization 拼接时 base64 编码没有处理中文符号。解决方法是检查系统时间是否自动同步在buildIatWebSocketUrl里把 date、signature、authorization 三个值打日志逐项核对。这条我踩过的次数最多每次换电脑都要重新排一次。第二个现象识别结果完全乱码返回文本和语音内容毫无关联。原因不是识别模型问题而是采样率不匹配。前端用 48kHz 采集后没有重采样就直接编码成 PCM 发送讯飞按 16kHz 解码语音音调被整体拉高或拉低识别结果自然全错。解决方法是确认发送链路里调用了resampleTo16k并且在buildAudioFrame的 format 字段里固定写audio/L16;rate16000。第三个现象首句识别很快但后续的识别文本一直不更新。常见原因是业务参数里没有加dwa: wpgs服务端默认只在整句结束后返回完整结果中间过程不推送。解决方法是检查 business 参数确认dwa已开启如果没开启只能等 vad_eos 静默结束实时性会差一大截。第四个现象点击停止按钮后WebSocket 直接 close最后一个词识别结果丢失。原因是主动结束前没有发送 status2 的结束帧服务端还没来得及吐出完整结果就被客户端掐断了。解决方法是把结束流程改成发送结束帧等待服务端返回最终结果再关闭连接。这里有血泪教训很多团队在演示时最后几个字永远出不来就是死在这条顺序上。5.2 存证链路中的时间不一致与重复提交第五个现象存证记录里展示的时间戳和本地实际说话时间差了几分钟。原因是前端提交 indexedRecord 时用的 startedAt 是浏览器时间而存证服务记录的是网关收到请求的区块链时间两者天然存在偏差。解决方法是不要试图消除偏差而是在索引里同时记录clientStartedAt和serverReceivedAt两个字段审计时两个时间都展示并允许一定范围的偏差。这个设计不完美但诚实能避免很多不必要的投诉。第六个现象网络抖动导致存证请求超时前端重试后同一个 sessionId 生成了多条存证记录。原因是提交接口没有做幂等控制。解决方法是存证请求携带sessionId作为幂等键平台侧按 sessionId 去重前端重试时不要重新生成 sessionId。另一个细节是textDigest必须在最终文本确定后计算如果重试时文本还没落定前后两次的哈希不一致存证就变成两条不同记录。我在实现里会把 textDigest 的计算和最终文本的赋值放同一个事务流程保证一致性。6. 端到端验收一套能见真章的验证流程流式听写加了可信存证之后验收就不只是看字幕跟不跟得上了还要验证数据能不能闭环。我建议准备一个固定长度的 WAV 测试文件用脚本按 40ms 切块模拟真实发送先脱离麦克风验证讯飞接口本身通过后再接 AudioWorklet 走真实麦克风链路。两轮测试的结果文本必须一致不一致就说明采集或重采样环节有问题。验证存证链路时可以做一个简单的三步检查存证调用成功后把返回的 evidenceId 和本地 indexedRecord 关联存储然后重新打开会话用同一段音频重新计算audioDigest对比与存证记录里的值是否一致最后用最终的 finalText 重新计算textDigest如果不一致说明存证提交的时机不在最终文本落定之后。这三步走通整个链路的数据可信度才算立住。实际部署时我习惯在 iatStore 里保留最近 50 个会话的索引记录方便事后抽检。这个设计不复杂但对线上问题排查帮助极大遇到用户投诉时能直接定位到具体会话的音频哈希、文本哈希和存证编号。语音流式识别本质上是一个高实时、高偶发问题的场景把连接管理、状态推进和存证摘要三件事做成同一套状态机比在组件里临时拼装要稳得多。希望这篇文章能帮你在讯飞语音听写流式实现这条路上少走几个来回尤其是最后那段存证闭环早做比晚做省心得多。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →