news 2026/10/18 17:25:45

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
科大讯飞语音听写流式实现: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}", algorithm="hmac-sha256", ` + `headers="host 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;rate=16000", "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;rate=16000', encoding: 'raw', audio: audioBase64 } }; }

打包逻辑里最关键的是 status 的推进。首帧固定为 0,中间持续发送时固定为 1,最后一帧为 2。有一个非常隐蔽的坑:如果音频静默时间超过 vad_eos,服务端会自动结束识别,这时候你再去发 status=2 的结束帧,服务端可能已经关掉了连接,WebSocket 就会报一个莫名其妙的关闭码。更稳的做法是:主动结束识别时先发 status=2 的帧,收到服务端返回的最终识别结果后再手动 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; charset=utf-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;rate=16000。

第三个现象:首句识别很快,但后续的识别文本一直不更新。常见原因是业务参数里没有加dwa: 'wpgs',服务端默认只在整句结束后返回完整结果,中间过程不推送。解决方法是检查 business 参数,确认dwa已开启;如果没开启,只能等 vad_eos 静默结束,实时性会差一大截。

第四个现象:点击停止按钮后,WebSocket 直接 close,最后一个词识别结果丢失。原因是主动结束前没有发送 status=2 的结束帧,服务端还没来得及吐出完整结果就被客户端掐断了。解决方法是把结束流程改成:发送结束帧,等待服务端返回最终结果,再关闭连接。这里有血泪教训,很多团队在演示时最后几个字永远出不来,就是死在这条顺序上。

5.2 存证链路中的时间不一致与重复提交

第五个现象:存证记录里展示的时间戳和本地实际说话时间差了几分钟。原因是前端提交 indexedRecord 时用的 startedAt 是浏览器时间,而存证服务记录的是网关收到请求的区块链时间,两者天然存在偏差。解决方法是不要试图消除偏差,而是在索引里同时记录clientStartedAt和serverReceivedAt两个字段,审计时两个时间都展示,并允许一定范围的偏差。这个设计不完美,但诚实,能避免很多不必要的投诉。

第六个现象:网络抖动导致存证请求超时,前端重试后同一个 sessionId 生成了多条存证记录。原因是提交接口没有做幂等控制。解决方法是存证请求携带sessionId作为幂等键,平台侧按 sessionId 去重;前端重试时不要重新生成 sessionId。另一个细节是:textDigest必须在最终文本确定后计算,如果重试时文本还没落定,前后两次的哈希不一致,存证就变成两条不同记录。我在实现里会把 textDigest 的计算和最终文本的赋值放同一个事务流程,保证一致性。

6. 端到端验收:一套能见真章的验证流程

流式听写加了可信存证之后,验收就不只是看字幕跟不跟得上了,还要验证数据能不能闭环。我建议准备一个固定长度的 WAV 测试文件,用脚本按 40ms 切块模拟真实发送,先脱离麦克风验证讯飞接口本身;通过后再接 AudioWorklet 走真实麦克风链路。两轮测试的结果文本必须一致,不一致就说明采集或重采样环节有问题。

验证存证链路时,可以做一个简单的三步检查:存证调用成功后,把返回的 evidenceId 和本地 indexedRecord 关联存储;然后重新打开会话,用同一段音频重新计算audioDigest,对比与存证记录里的值是否一致;最后用最终的 finalText 重新计算textDigest,如果不一致,说明存证提交的时机不在最终文本落定之后。这三步走通,整个链路的数据可信度才算立住。

实际部署时,我习惯在 iatStore 里保留最近 50 个会话的索引记录,方便事后抽检。这个设计不复杂,但对线上问题排查帮助极大,遇到用户投诉时能直接定位到具体会话的音频哈希、文本哈希和存证编号。语音流式识别本质上是一个高实时、高偶发问题的场景,把连接管理、状态推进和存证摘要三件事做成同一套状态机,比在组件里临时拼装要稳得多。希望这篇文章能帮你在讯飞语音听写流式实现这条路上少走几个来回,尤其是最后那段存证闭环,早做比晚做省心得多。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/18 17:22:33

MATLAB无人机路径规划源码:A*算法从二维到三维的避障与航线导出

简介&#xff1a;这份MATLAB无人机路径规划源码包面向无人机自主导航方向的学习者与研究者&#xff0c;聚焦栅格地图构建、激光雷达扫描模拟与A 算法改进三大核心问题&#xff0c;适合具备一定MATLAB编程基础、希望动手复现路径规划全流程的读者。包内共14个文件&#xff0c;以…

作者头像 李华
网站建设 2026/10/18 17:21:35

软考系统架构师决策树笔记:从知识索引到解题引擎

简介&#xff1a;本资源是面向全国计算机技术与软件专业技术资格&#xff08;水平&#xff09;考试中‘系统架构设计师’科目的高效备考资料&#xff0c;专为冲刺阶段考生设计&#xff0c;聚焦核心考点提炼与知识体系梳理。文件为单个PDF文档&#xff0c;共173.72MB&#xff0c…

作者头像 李华
网站建设 2026/10/18 17:18:58

Flutter+OpenHarmony详情页关注收藏实战:状态管理与跨平台适配

接手市面上那个内容社区类项目时&#xff0c;详情页一直是需求改动最频繁&#xff0c;也是最容易出状态错乱问题的页面。尤其是“关注”和“收藏”这两个操作&#xff0c;看着只是两个按钮&#xff0c;背后却牵扯到全局状态同步、本地持久化、网络请求重试&#xff0c;以及跨平…

作者头像 李华
网站建设 2026/10/18 17:17:44

数学分析讲义怎么用?从极限到级数的学习路线与避坑指南

简介&#xff1a;丘成桐先生编写的《数学分析之课程讲义》是面向清华大学丘成桐数学英才班的实验课教材&#xff0c;旨在不改变传统教学根本的前提下&#xff0c;探索更深入、更现代的分析学内容。讲义覆盖数列极限、函数连续性、黎曼积分、子流形理论、抽象测度论等基础&#…

作者头像 李华
网站建设 2026/10/18 17:17:37

Python+pywinauto实战:Windows GUI自动化测试从入门到稳定

做了两年Windows客户端测试之后&#xff0c;我最大的一个感受就是&#xff1a;手工回归这事儿&#xff0c;干一次可以接受&#xff0c;干十次还能忍&#xff0c;干上百次真的会让人怀疑人生。所以当项目组提出“把核心功能的回归测试自动化起来”这个需求时&#xff0c;我几乎没…

作者头像 李华