简介:本资源是一个基于Vue.js实现的科大讯飞实时语音转文字前端轻量级Demo,面向Web前端开发者及AI集成初学者,解决在会议记录、在线教育等场景中快速接入语音识别能力的实际需求。压缩包共8个文件(7个JS脚本+1个HTML主入口),总大小仅16KB,其中包含核心SDK封装(index.umd.js)、音频预处理逻辑(processor.worker.js)、Base64与哈希加密工具(enc-base64-min.js、md5.js、HmacSHA1.js、hmac-sha256.js)及启动页,结构紧凑、依赖零外部库,便于理解流式语音识别的前端调用链路与鉴权机制。目前已有4431人学习下载,读者可直接运行调试,掌握MediaDevices录音采集、WebSocket/HTTP流式上传、科大讯飞WebAPI鉴权与分片识别响应解析等关键实现细节,并复用其模块化设计思路快速集成至自有Vue项目。
1. 科大讯飞实时语音转文字 Vue.js 版本:不是调个 SDK 就能跑通的 WebAPI 集成,而是要亲手把 WebSocket 握手、音频流切片、状态机同步、错误重连这四道关全过一遍
你肯定试过直接 npm install xunfei-web-sdk,然后在 mounted 里 new SpeechRecognizer() —— 页面一打开,控制台飘红:WebSocket connection to 'wss://ws-api.xfyun.cn/v2/iat' failed。不是密钥错了,不是域名没配 CORS,是根本没理解科大讯飞 WebAPI 的语音识别不是 RESTful 请求,而是一套带严格生命周期的长连接协议。这个 Vue.js 版本资源包,不是封装好的黑匣子,而是一份可调试、可打断点、可改采样率、可手动注入 PCM 数据的实战工程。它完整覆盖了从用户点击「开始说话」按钮,到麦克风权限获取、音频上下文初始化、16kHz 单声道 PCM 流采集、每 40ms 切片编码、WebSocket 帧发送、服务端响应解析、实时文本流拼接、断线自动重试(含 token 过期刷新)的全链路。适合正在做在线会议字幕、远程教学实时听写、或需要嵌入语音输入能力的中后台系统的 Vue 2/3 开发者。如果你只需要“一句话识别”,用官方 demo 就够;但你要的是“边说边出字、卡顿不丢字、断网后自动续上”,那这份代码就是你翻车三次后抄来的血泪经验。
2. 为什么必须自己实现 WebSocket 封装:绕不开的鉴权机制、帧格式与状态同步
科大讯飞 IAT(智能语音听写)WebAPI 不是发个 POST 就完事。它的通信模型分三阶段:握手鉴权 → 音频流传输 → 结果返回 + 关闭。每个阶段都有强状态依赖,且官方 JS SDK 在 Vue 场景下存在生命周期绑定混乱、多次实例化导致连接冲突、无法细粒度控制音频采集节奏等问题。我们不用 SDK,是因为要掌控四个不可妥协的环节:鉴权 token 必须动态生成且带时间戳防重放;音频帧必须按 40ms 精确切片并填充common+business+data三层 JSON 结构;WebSocket 连接必须与 Vue 组件生命周期解耦,支持手动 pause/resume;识别结果必须按cn字段逐字流式拼接,而非等type === "end"才渲染。下面拆解核心实现逻辑。
2.1 鉴权 Token 动态生成:不是静态配置,而是每次连接前实时请求
科大讯飞要求 WebSocket 握手 URL 中携带authorization参数,该参数由api_key:api_secret经 HMAC-SHA256 签名 + Base64 编码生成,且必须包含当前 Unix 时间戳(精确到秒)和有效期(默认 3600 秒)。关键点在于:token 有效期仅 1 小时,且同一 token 不能重复使用。若组件挂载时生成一次就缓存,1 小时后连接必然失败。
// utils/auth.js export function generateXfAuthHeader(appId, apiKey, apiSecret) { const ts = Math.floor(Date.now() / 1000); // 秒级时间戳 const exp = ts + 3600; // 有效期 1 小时 const checkSum = CryptoJS.enc.Base64.stringify( CryptoJS.HmacSHA256(`${apiKey}${ts}${exp}`, apiSecret) ); return `hmac username="${apiKey}", algorithm="hmac-sha256", headers="x-date: host", signature="${checkSum}"`; } // 构建 WebSocket URL(注意:x-date 必须是 RFC1123 格式) export function buildWsUrl(appId, apiKey, apiSecret) { const ts = new Date().toUTCString(); // 'Mon, 01 Jan 2024 00:00:00 GMT' const auth = generateXfAuthHeader(appId, apiKey, apiSecret); return `wss://ws-api.xfyun.cn/v2/iat?appid=${appId}` + `&host=ws-api.xfyun.cn` + `&date=${encodeURIComponent(ts)}` + `&authorization=${encodeURIComponent(auth)}`; }提示:
x-date头必须用 UTC 时间字符串,且需encodeURIComponent;authorization中的signature是对apiKey + timestamp + expire三元组签名,不是对整个 URL 签名。漏掉encodeURIComponent或时间格式错,99% 的401 Unauthorized都源于此。
2.2 WebSocket 连接管理:状态机驱动,非简单 onopen/onmessage
我们定义一个SpeechSocket类,内部维护IDLE → CONNECTING → CONNECTED → CLOSING → CLOSED五种状态,并禁止在非CONNECTED状态下发音频帧。连接过程需监听onopen、onmessage、onerror、onclose四个事件,且onmessage必须解析二进制 ArrayBuffer(服务端返回的是二进制帧,非纯文本 JSON)。
// services/speech-socket.js export class SpeechSocket { constructor(options) { this.appId = options.appId; this.apiKey = options.apiKey; this.apiSecret = options.apiSecret; this.ws = null; this.status = 'IDLE'; this.reconnectTimer = null; } connect() { if (this.status !== 'IDLE' && this.status !== 'CLOSED') return; const url = buildWsUrl(this.appId, this.apiKey, this.apiSecret); this.ws = new WebSocket(url); this.status = 'CONNECTING'; this.ws.onopen = () => { this.status = 'CONNECTED'; console.log('[SpeechSocket] WebSocket connected'); // 发送初始化帧(必须在 onopen 后立即发) this.sendInitFrame(); }; this.ws.onmessage = (event) => { if (event.data instanceof ArrayBuffer) { this.handleBinaryMessage(event.data); } else { console.warn('[SpeechSocket] Unexpected text message:', event.data); } }; this.ws.onerror = (err) => { console.error('[SpeechSocket] WebSocket error:', err); this.handleConnectionError(); }; this.ws.onclose = (e) => { console.log(`[SpeechSocket] WebSocket closed: ${e.code} ${e.reason}`); this.status = 'CLOSED'; this.handleConnectionClose(e.code); }; } sendInitFrame() { const frame = { common: { app_id: this.appId }, business: { language: 'zh_cn', domain: 'iat', accent: 'mandarin', vad_eos: 5000, // 静音超时 5s dwa: 'wpgs', // 是否开启语义断句 }, data: { status: 0, // 0=首帧,1=中间帧,2=末帧 format: 'audio/L16;rate=16000', audio: '', // base64 编码的 PCM 数据,首帧为空 encoding: 'raw' } }; this.ws.send(JSON.stringify(frame)); } }说明:
sendInitFrame()是握手后的第一帧,status: 0表示这是首帧,audio: ''表示无音频数据;vad_eos控制服务端静音检测时长,设太小会导致说话停顿就被截断,设太大则响应延迟高;dwa: 'wpgs'启用语义断句,能让“今天天气很好”分词为“今天/天气/很好”,而非机械按字切。
2.3 音频采集与 PCM 编码:绕过 MediaRecorder 的坑,直取 AudioContext 输出
MediaRecorder输出的是webm或mp4,而科大讯飞只认L16格式 PCM(16bit 线性量化,小端序)。我们必须用AudioContext+ScriptProcessorNode(Vue 2)或AudioWorklet(Vue 3)实时捕获麦克风原始 PCM。以下为 Vue 2 兼容方案(Vue 3 请替换为 AudioWorklet):
// services/audio-capture.js export class AudioCapture { constructor() { this.context = null; this.analyser = null; this.processor = null; this.stream = null; this.isCapturing = false; } async start(stream) { this.stream = stream; this.context = new (window.AudioContext || window.webkitAudioContext)(); const source = this.context.createMediaStreamSource(stream); // 创建 ScriptProcessorNode(已废弃但兼容性好) this.processor = this.context.createScriptProcessor(4096, 1, 1); source.connect(this.processor); this.processor.connect(this.context.destination); this.processor.onaudioprocess = (e) => { if (!this.isCapturing) return; const inputBuffer = e.inputBuffer; const inputData = inputBuffer.getChannelData(0); // 取左声道 const pcm16 = this.float32ToPCM16(inputData); // 转 16bit 整数 this.onAudioChunk(pcm16); }; } float32ToPCM16(floatArray) { const int16Array = new Int16Array(floatArray.length); for (let i = 0; i < floatArray.length; i++) { // float32 范围 [-1, 1] → int16 [-32768, 32767] int16Array[i] = Math.max(-32768, Math.min(32767, Math.floor(floatArray[i] * 32767))); } return int16Array; } onAudioChunk(pcm16) { // 每 40ms 数据量 = 16000 Hz × 0.04 s = 640 个样本 → 1280 字节(每个样本 2 字节) if (pcm16.length >= 640) { const chunk = pcm16.slice(0, 640); const base64 = this.arrayBufferToBase64(chunk.buffer); // 触发事件,交由 SpeechSocket 发送 this.$emit('audio-chunk', base64); } } arrayBufferToBase64(buffer) { let binary = ''; const bytes = new Uint8Array(buffer); for (let i = 0; i < bytes.length; i++) { binary += String.fromCharCode(bytes[i]); } return btoa(binary); } }注意:
ScriptProcessorNode在 Chrome 71+ 已标记为 deprecated,但目前仍是 Vue 2 项目最稳定方案;float32ToPCM16必须做饱和截断(Math.max/min),否则溢出会爆音;640样本是硬性要求,少于该值服务端会报frame size error。
3. 实时文本流拼接与状态同步:别让「正在识别」按钮永远转圈
识别结果不是一次性返回,而是以type: "partial"(中间结果)、type: "final"(最终结果)、type: "end"(会话结束)三类消息分批推送。若不做状态同步,用户看到的可能是「你好啊」→「你好啊吗」→「你好啊吗今天」这种跳变。我们必须维护一个currentText和finalText双缓冲,并按seq字段排序合并。
3.1 消息解析与类型路由:从 ArrayBuffer 解析 JSON
服务端返回的onmessage是 ArrayBuffer,需先转为字符串再 JSON.parse:
// services/speech-socket.js(续) handleBinaryMessage(arrayBuffer) { const decoder = new TextDecoder('utf-8'); const jsonString = decoder.decode(arrayBuffer); let msg; try { msg = JSON.parse(jsonString); } catch (e) { console.error('[SpeechSocket] Failed to parse message:', jsonString); return; } switch (msg.action) { case 'started': this.emit('start', msg); break; case 'result': this.handleResult(msg); break; case 'error': this.emit('error', msg); break; case 'completed': this.emit('complete', msg); break; } } handleResult(msg) { if (!msg.data || !msg.data.result) return; const result = msg.data.result; const type = result.type || 'partial'; // 默认 partial const seq = result.seq || 0; const text = result.text || ''; // 按 seq 排序,避免网络乱序 this.resultQueue.push({ seq, type, text }); this.resultQueue.sort((a, b) => a.seq - b.seq); // 只取最新 seq 的 partial,final 覆盖 partial const latestPartial = this.resultQueue.find(r => r.type === 'partial'); const latestFinal = this.resultQueue.find(r => r.type === 'final'); if (latestFinal) { this.currentText = latestFinal.text; this.finalText = latestFinal.text; } else if (latestPartial) { this.currentText = latestPartial.text; } this.emit('text-update', { current: this.currentText, final: this.finalText, isFinal: !!latestFinal }); }说明:
seq是服务端保证递增的序列号,必须用它排序,不能依赖接收顺序;currentText用于实时显示(可能被后续 partial 覆盖),finalText是确认无误的最终文本;isFinal: true时才应将文本提交到业务逻辑(如插入聊天框)。
3.2 Vue 组件集成:Composition API 封装(Vue 3)
<!-- components/SpeechInput.vue --> <template> <div class="speech-input"> <button @click="toggle" :disabled="isProcessing"> {{ isListening ? '停止' : '开始说话' }} </button> <div class="transcript"> <span v-if="isListening">正在识别:{{ currentText }}</span> <span v-else-if="finalText">已识别:{{ finalText }}</span> <span v-else>等待开始...</span> </div> </div> </template> <script setup> import { ref, onMounted, onUnmounted } from 'vue'; import { SpeechSocket } from '@/services/speech-socket'; import { AudioCapture } from '@/services/audio-capture'; const props = defineProps({ appId: String, apiKey: String, apiSecret: String }); const isListening = ref(false); const isProcessing = ref(false); const currentText = ref(''); const finalText = ref(''); let socket = null; let audioCapture = null; let mediaStream = null; const toggle = async () => { if (isListening.value) { stopListening(); } else { await startListening(); } }; const startListening = async () => { try { isProcessing.value = true; // 1. 获取麦克风权限 mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true }); // 2. 初始化音频采集 audioCapture = new AudioCapture(); audioCapture.start(mediaStream); // 3. 初始化 WebSocket socket = new SpeechSocket({ appId: props.appId, apiKey: props.apiKey, apiSecret: props.apiSecret }); // 4. 绑定事件 socket.on('start', () => { console.log('Recognition started'); isListening.value = true; isProcessing.value = false; }); socket.on('text-update', (data) => { currentText.value = data.current; if (data.isFinal) { finalText.value = data.final; } }); socket.on('error', (err) => { console.error('Recognition error:', err); alert(`识别失败:${err.desc || '未知错误'}`); stopListening(); }); socket.on('complete', () => { console.log('Recognition completed'); stopListening(); }); // 5. 建立连接 socket.connect(); } catch (err) { console.error('Start failed:', err); alert(`启动失败:${err.message}`); isProcessing.value = false; } }; const stopListening = () => { if (socket) { socket.close(); socket = null; } if (audioCapture) { audioCapture.stop(); audioCapture = null; } if (mediaStream) { mediaStream.getTracks().forEach(track => track.stop()); mediaStream = null; } isListening.value = false; currentText.value = ''; }; onUnmounted(() => { stopListening(); }); </script>关键设计:
isProcessing独立于isListening,用于禁用按钮防止重复点击;mediaStream.getTracks().forEach(track => track.stop())必须显式调用,否则麦克风常开耗电;onUnmounted清理所有资源,避免内存泄漏。
4. 避坑:WebSocket 连接、音频流、状态机三大高频翻车现场
科大讯飞 WebAPI 的坑不在文档里,而在你第一次onmessage收不到任何数据的时候。以下是我在三个真实项目中踩出的血泪记录,每一条都对应一个console.error截图和半小时排查。
4.1 现象:WebSocket 连接成功(onopen 触发),但onmessage完全不触发,控制台无报错
原因:未在onopen后立即发送首帧(status: 0的初始化帧)。科大讯飞服务端在握手完成后,会等待客户端发送首帧,若 5 秒内未收到,则主动关闭连接,且不发任何错误帧。
解决:严格在ws.onopen回调内调用sendInitFrame(),并在sendInitFrame()内打印日志确认执行。可在 Chrome DevTools 的 Network → WS → Frames 标签页查看是否发出首帧。
4.2 现象:识别结果中文字乱码(如ä½ å¥½),或部分汉字缺失
原因:arrayBufferToBase64实现错误。Uint8Array转字符串时,若直接String.fromCharCode(...bytes),当字节值 > 127 时会被解释为 UTF-16 代理对,导致乱码。正确做法是用TextDecoder('utf-8')解码,但此处我们处理的是二进制 PCM,无需解码,应直接用btoa对原始字节流编码。
解决:将arrayBufferToBase64改为:
arrayBufferToBase64(buffer) { let binary = ''; const bytes = new Uint8Array(buffer); const len = bytes.byteLength; for (let i = 0; i < len; i++) { binary += String.fromCharCode(bytes[i]); } return btoa(binary); }注意:必须用
Uint8Array遍历,不能用Int16Array,否则会读错字节序。
4.3 现象:用户说话时,currentText频繁跳变(如「今天」→「今天天」→「今天天气」),但最终finalText正确
原因:前端未做seq排序,且partial消息未按序合并。服务端可能因网络抖动,先发seq: 3的 partial,再发seq: 2的 partial,若前端按接收顺序覆盖currentText,就会出现倒退。
解决:强制维护resultQueue数组,每次handleResult时push并sort((a,b) => a.seq - b.seq),再取队列末尾的partial作为当前显示文本。不要用Map或对象 key 存储,避免覆盖。
4.4 现象:页面切换路由后,SpeechSocket未销毁,导致新页面再次点击「开始」时,旧连接仍在发送音频,新连接又建一个,造成音频混叠、识别结果错乱
原因:SpeechSocket实例未与 Vue 组件生命周期绑定,onUnmounted未调用socket.close()和audioCapture.stop()。
解决:在组件onUnmounted钩子中,必须显式调用socket.close()、audioCapture.stop()、mediaStream.getTracks().forEach(track => track.stop())。可在SpeechSocket类中增加isClosed标志位,send()方法开头加if (this.status !== 'CONNECTED') return防御。
4.5 现象:Chrome 浏览器下识别正常,Safari 报NotAllowedError: The request is not allowed by the user agent or the platform in the current context
原因:Safari 要求AudioContext必须在用户手势(如 click)回调中创建,且不能跨域 iframe。若startListening不在 button@click内直接调用(如被 Promise 包裹、被 setTimeout 延迟),Safari 会拒绝激活音频上下文。
解决:确保navigator.mediaDevices.getUserMedia和new AudioContext()都在原生 click 事件处理器内同步执行,不要加任何异步包装。Vue 的nextTick也不行,必须是 click 回调栈内。
5. 断线重连与 token 刷新:让语音识别在弱网下真正可用的两个硬核技巧
真正的生产环境不是实验室——WiFi 会波动、4G 会降速、用户会切后台。科大讯飞 WebAPI 的连接稳定性,90% 取决于你如何处理onclose和onerror。这里不讲理论,只给两条我压箱底的技巧:指数退避重连和token 预加载机制。
5.1 指数退避重连:别让 10 次重连把服务器打挂
简单setTimeout(() => connect(), 1000)在网络抖动时会引发雪崩。正确做法是:首次失败等 1s,第二次等 2s,第三次等 4s……最大不超过 30s,并在重连前检查网络状态。
// services/speech-socket.js(续) handleConnectionClose(code) { if (code === 1000 || code === 1001) { // 正常关闭,不重连 return; } // 非正常关闭,启动重连 this.reconnectAttempts = this.reconnectAttempts || 0; this.reconnectAttempts++; const delay = Math.min(30000, Math.pow(2, this.reconnectAttempts) * 1000); // 1s, 2s, 4s... max 30s console.log(`[SpeechSocket] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`); this.reconnectTimer = setTimeout(() => { if (this.status === 'CLOSED') { this.connect(); } }, delay); } handleConnectionError() { // 错误时也走同一套重连逻辑 this.handleConnectionClose(0); }提示:
reconnectAttempts计数器必须在connect()成功后重置为 0,否则会越积越多;clearTimeout(this.reconnectTimer)要在connect()开头执行,避免重复定时器。
5.2 Token 预加载机制:让重连不因 token 过期而失败
前面说过 token 有效期 1 小时,但重连可能发生在第 59 分钟。若此时buildWsUrl()用旧时间戳生成 token,连接必 401。解决方案:在 token 过期前 5 分钟,预先请求新 token 并缓存。
// utils/auth.js(增强版) let cachedToken = null; let tokenExpiry = 0; export async function getValidXfToken(appId, apiKey, apiSecret) { const now = Date.now(); if (cachedToken && tokenExpiry > now + 300000) { // 提前 5 分钟刷新 return cachedToken; } // 请求新 token(此处调用你自己的后端接口,不要前端直连讯飞) try { const res = await fetch('/api/xf-token', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ appId, apiKey, apiSecret }) }); const data = await res.json(); cachedToken = data.token; tokenExpiry = Date.now() + data.expires_in * 1000; // expires_in 是秒数 return cachedToken; } catch (e) { console.error('Failed to fetch XF token:', e); throw e; } } // 修改 buildWsUrl,不再直接生成,而是 await 获取 export async function buildWsUrlWithToken(appId, apiKey, apiSecret) { const token = await getValidXfToken(appId, apiKey, apiSecret); const ts = new Date().toUTCString(); return `wss://ws-api.xfyun.cn/v2/iat?appid=${appId}` + `&host=ws-api.xfyun.cn` + `&date=${encodeURIComponent(ts)}` + `&authorization=${encodeURIComponent(token)}`; }关键点:token 必须由后端生成并返回。前端持有
apiSecret是严重安全风险,所有签名操作必须在服务端完成;/api/xf-token接口应做频率限制(如每 IP 每分钟最多 5 次),防止被恶意刷 token。
5.3 验证你的实现是否真正健壮:三步压力测试法
别信日志,要真测。我每次交付前必做这三步:
- 模拟断网:Chrome DevTools → Network → Offline,点击「开始说话」,等 10 秒,再切回 Online,观察是否自动重连并继续识别;
- 强制 token 过期:修改本地系统时间到 1 小时后,启动页面,看首次连接是否 401,重连后是否恢复正常;
- 长时运行:让页面持续识别 65 分钟,期间不操作,看第 60 分钟是否自动刷新 token,第 65 分钟是否仍能识别。
从那以后我每次上线新语音功能,都强制走一遍这三步。不是怕出问题,是怕用户在重要会议里听到「识别失败」的提示音——那比任何 bug 都致命。希望帮到你。
本文还有配套的精品资源,点击获取