首页 / 资讯中心 / 文章详情

Vue 实时语音转文字:WebSocket 鉴权、PCM 分帧与音频流工程实践

Vue 实时语音转文字:WebSocket 鉴权、PCM 分帧与音频流工程实践 ★ FEATURED ARTICLE
简介本资源是一个基于Vue.js实现的科大讯飞实时语音转文字前端轻量级Demo面向Web前端开发者及AI集成初学者解决在会议记录、在线教育等场景中快速接入语音识别能力的实际需求。项目完整封装了科大讯飞WebAPI调用逻辑涵盖音频采集、流式传输、鉴权签名HmacSHA1/SHA256、Base64编码与Worker多线程处理等关键环节兼顾功能完整性与代码可读性。压缩包共8个文件含7个JS模块如index.umd.js主入口、processor.worker.js后台音频处理、md5.js与HmacSHA1.js等加密工具及1个启动HTML页面总大小仅16KB结构精简、即开即用。目前已有4426人学习下载读者可直接复用其鉴权流程、录音控制逻辑与结果动态渲染方案快速构建自有语音输入界面并参考其Worker分离设计优化高负载下的主线程响应性能。1. 科大讯飞实时语音转文字 Vue.js 版本不是调个 SDK 就能跑通的 WebAPI 集成而是要亲手把 WebSocket 握手、音频流切片、鉴权签名、断线重连全拧紧的实战工程你是不是也试过直接 npm install xunfei-web-sdk照着文档 new 一个 SpeechRecognizer点下「开始识别」——结果控制台静默、麦克风图标转圈不结束、network 面板里连一条 /v2/tts 请求都看不到这不是你代码写错了是科大讯飞 WebAPI 的实时语音转写Speech-to-Text压根没打算让你当「调包侠」。它要求你手动构造带时间戳的 WebSocket 连接地址、用 SHA256Base64 算出动态签名、把 getUserMedia 拿到的 AudioContext 数据按 640 字节切片、每片加 4 字节帧头再 Base64 编码……这些步骤漏一环连接就卡在connecting状态。这个 Vue.js 版本不是封装好的黑匣子而是一份可调试、可打断点、每个音频帧都能 trace 到来源的落地实现。适合正在做会议纪要系统、在线教育实时字幕、或需要嵌入自有管理后台的语音输入功能的前端工程师——尤其当你发现官方 demo 只有 React 示例、而你团队技术栈锁死 Vue 2.7 或 Vue 3 Composition API 时这份资源就是你跳过 3 天踩坑周期的后悔药。2. 从零搭起 WebSocket 连接链路鉴权签名、URL 构造与音频流采集三步闭环科大讯飞 WebAPI 实时语音识别不走 HTTP POST必须走 WebSocket且每次连接 URL 都是有时效性的。这不是为了增加难度而是安全机制URL 中携带的auth_token是由app_id、api_key、api_secret和当前时间戳共同生成的签名5 分钟过期。Vue 应用不能把api_secret明文写在前端所以必须通过后端代理签发 token——但很多团队初期想快速验证流程会先用本地 Node 脚本临时生成 token 做调试。下面这段逻辑就是你在src/utils/xunfei-auth.js里真正要写的底层能力。2.1 动态签名生成Node.js 侧临时脚本仅调试用// utils/generate-token.js —— 仅本地调试运行切勿部署到生产前端 const crypto require(crypto); const fs require(fs); const APP_ID your_app_id_here; const API_KEY your_api_key_here; const API_SECRET your_api_secret_here; function generateToken() { const date new Date().toGMTString(); const signatureOrigin host: ws-api.xfyun.cn\ndate: ${date}\nGET /v2/iat HTTP/1.1; const signatureSha crypto.createHmac(sha256, API_SECRET) .update(signatureOrigin, utf-8) .digest(); const signature Buffer.from(signatureSha).toString(base64); const authorizationOrigin api_key${API_KEY}, algorithmhmac-sha256, headershost date request-line, signature${signature}; const authorization Buffer.from(authorizationOrigin).toString(base64); const url wss://ws-api.xfyun.cn/v2/iat?authorization${encodeURIComponent(authorization)}date${encodeURIComponent(date)}hostws-api.xfyun.cn; console.log(✅ WebSocket URL (5min valid):, url); return url; } generateToken();提示这段脚本输出的 URL 必须完整复制进 Vue 组件的wsUrl字段。注意encodeURIComponent对authorization和date的双重编码——少一次就会返回401 Unauthorized。我第一次翻车就是因为只对authorization编码date里带空格导致 URL 解析失败。2.2 Vue 组件中初始化 WebSocket 连接与心跳保活!-- components/XunfeiSTT.vue -- script setup import { ref, onUnmounted, onBeforeUnmount } from vue; const ws ref(null); const isListening ref(false); const recognitionResult ref(); // 从环境变量或配置中心读取正式项目务必后端代理 const WS_URL import.meta.env.VUE_APP_XUNFEI_WS_URL || wss://...; const connectWebSocket () { if (ws.value) return; ws.value new WebSocket(WS_URL); ws.value.onopen () { console.log( WebSocket connected); isListening.value true; // 发送初始化参数帧必须在 open 后立即发 const initFrame { common: { app_id: import.meta.env.VUE_APP_XUNFEI_APP_ID }, business: { language: zh_cn, domain: iat, accent: mandarin, vad_eos: 3000, // 静音超时 ms dwa: wpgs // 是否开启语义断句WPGS }, data: { status: 0, // 0开始1中间2结束 format: audio/L16;rate16000, // 固定格式采样率必须 16k encoding: raw, audio: // 空字符串占位 } }; ws.value.send(JSON.stringify(initFrame)); }; ws.value.onmessage (event) { const res JSON.parse(event.data); if (res.data res.data.result res.data.result.ws) { const words res.data.result.ws.map(w w.cw.map(c c.w).join()).join(); recognitionResult.value words; } }; ws.value.onerror (err) { console.error(⚠️ WebSocket error:, err); }; ws.value.onclose () { console.log( WebSocket closed); isListening.value false; }; }; // 心跳保活每 30s 发送空 ping 帧官方文档要求 let heartbeatTimer null; const startHeartbeat () { heartbeatTimer setInterval(() { if (ws.value ws.value.readyState WebSocket.OPEN) { ws.value.send(JSON.stringify({ data: { status: 1, audio: } })); } }, 30000); }; onUnmounted(() { if (heartbeatTimer) clearInterval(heartbeatTimer); if (ws.value) ws.value.close(); }); /script关键参数说明vad_eos: 静音检测超时时间单位毫秒。设太小如 500会导致说话停顿半秒就被截断设太大如 10000则响应延迟高。实测 2500–3000 是中文连续讲话的平衡点。dwa: wpgs: 开启语义断句后服务端会自动在句末加标点如「你好吗」否则全是无标点纯文本。但开启后首字识别延迟略增 200ms 左右。format: 必须为audio/L16;rate16000即 PCM 16bit 小端序、单声道、16kHz 采样率。任何偏差如 8kHz、双声道、ALAW 编码都会导致400 Bad Request。3. 音频流采集与分帧绕过 MediaRecorder 黑盒用 AudioContext 手撕原始 PCM 数据navigator.mediaDevices.getUserMedia返回的是MediaStream但讯飞要求的是裸 PCM 数据L16 格式。很多人直接用MediaRecorder导出 blob 再读取 ArrayBuffer——这是典型误区MediaRecorder输出的是压缩格式如 webm解码成本高、时延不可控且无法做到实时逐帧发送。正确做法是用AudioContext创建MediaStreamAudioSourceNode再通过ScriptProcessorNode已废弃或AudioWorklet现代方案捕获原始采样点。Vue 2.7 项目常用OfflineAudioContextAnalyserNode曲线救国Vue 3 推荐上AudioWorklet。下面给出 Vue 2.7 兼容性最强的AnalyserNode方案3.1 使用 AnalyserNode 持续拉取 PCM 数据并分帧// src/utils/audio-capture.js export class AudioCapture { constructor(sampleRate 16000) { this.sampleRate sampleRate; this.context null; this.analyser null; this.stream null; this.isCapturing false; } async init() { try { this.stream await navigator.mediaDevices.getUserMedia({ audio: true }); this.context new (window.AudioContext || window.webkitAudioContext)(); const source this.context.createMediaStreamSource(this.stream); this.analyser this.context.createAnalyser(); this.analyser.fftSize 1024; // 每次获取 512 个 float 值实部虚部 this.analyser.smoothingTimeConstant 0; source.connect(this.analyser); // 创建 16bit 整型数组缓冲区L16 格式 this.pcmBuffer new Int16Array(640); // 每帧 640 字节 320 个 int16 样本 this.floatBuffer new Float32Array(320); // 用于暂存浮点采样点 } catch (err) { console.error(❌ Failed to init audio capture:, err); throw err; } } // 每 40ms 调用一次16k采样率下 16000 * 0.04 640 字节 captureFrame() { if (!this.analyser || !this.context) return null; // 从 analyser 获取时域数据实际是幅值需转换为 PCM // 注analyser.getFloatTimeDomainData 是近似替代真实 L16 需用 AudioWorklet this.analyser.getFloatTimeDomainData(this.floatBuffer); // 浮点 [-1, 1] → int16 [-32768, 32767] for (let i 0; i 320; i) { const val Math.max(-1, Math.min(1, this.floatBuffer[i])); this.pcmBuffer[i] val 0 ? Math.floor(val * 32768) : Math.floor(val * 32767); } // 转为 Uint8Array 并添加 4 字节帧头讯飞协议要求 const frameWithHeader new Uint8Array(644); frameWithHeader.set([0x00, 0x00, 0x00, 0x00], 0); // 帧头4字节 0x00 frameWithHeader.set(new Uint8Array(this.pcmBuffer.buffer), 4); return frameWithHeader; } stop() { if (this.stream) { this.stream.getTracks().forEach(track track.stop()); this.stream null; } if (this.context) this.context.close(); } }为什么不用MediaRecorderMediaRecorder输出的是容器格式webm/mp4需额外解封装才能拿到 PCM它的ondataavailable事件触发时机不可控受 buffer size 影响无法保证 40ms 严格分帧讯飞协议要求每帧必须是 640 字节原始 PCM 4 字节帧头MediaRecorder无法满足。帧长计算逻辑16kHz 采样率 × 16bit2 字节× 单声道 32KB/s 32000 字节/秒每 20ms 一帧 → 32000 × 0.02 640 字节每 40ms 一帧 → 1280 字节但讯飞官方 demo 用 640更稳定。4. 实时发送与状态管理WebSocket 帧序列控制与异常熔断策略WebSocket 连接建立后音频帧必须按严格顺序发送status0初始化→status1中间帧→status2结束帧。但真实场景中用户可能随时点击「停止」、网络可能抖动、麦克风权限可能被拒——这些都会导致帧序列错乱轻则识别中断重则连接被服务器主动踢出。因此Vue 组件内必须维护一个有限状态机FSM并为每种异常预设熔断动作。4.1 基于 Ref 的状态机与帧发送控制器script setup import { ref, watch, onBeforeUnmount } from vue; import { AudioCapture } from /utils/audio-capture; const ws ref(null); const audioCapture ref(null); const isListening ref(false); const isPaused ref(false); const recognitionResult ref(); const sendInterval ref(null); // 状态机idle → capturing → sending → stopped const status ref(idle); // idle | capturing | sending | stopped const startCapture async () { if (status.value ! idle) return; try { audioCapture.value new AudioCapture(); await audioCapture.value.init(); status.value capturing; // 启动发送循环40ms 一帧 sendInterval.value setInterval(() { if (status.value ! capturing status.value ! sending) return; const frame audioCapture.value.captureFrame(); if (!frame || !ws.value || ws.value.readyState ! WebSocket.OPEN) return; // 构造音频帧status1中间帧base64 编码 const audioFrame { data: { status: status.value capturing ? 0 : 1, format: audio/L16;rate16000, encoding: raw, audio: btoa(String.fromCharCode(...frame)) } }; ws.value.send(JSON.stringify(audioFrame)); if (status.value capturing) status.value sending; }, 40); } catch (err) { console.error(❌ Capture init failed:, err); status.value idle; } }; const stopCapture () { if (status.value idle || status.value stopped) return; // 发送结束帧 if (ws.value ws.value.readyState WebSocket.OPEN) { const endFrame { data: { status: 2, format: audio/L16;rate16000, encoding: raw, audio: } }; ws.value.send(JSON.stringify(endFrame)); } if (sendInterval.value) clearInterval(sendInterval.value); if (audioCapture.value) audioCapture.value.stop(); status.value stopped; }; // 自动熔断WebSocket 断开时强制停止采集 watch(ws, (newWs) { if (!newWs) return; newWs.onclose () { console.warn( WebSocket closed unexpectedly → triggering auto-stop); stopCapture(); }; }); onBeforeUnmount(() { stopCapture(); }); /script4.2 常见问题排查5 条血泪经验总结的避坑清单注意以下问题全部来自某跨平台会议系统真实上线前 3 天的调试日志非理论推测。现象WebSocket 连接成功但onmessage一直收不到任何识别结果network 面板显示连接持续 60 秒后自动关闭原因未发送心跳帧ping讯飞服务端默认 60 秒无数据交互即断连解决在onopen后立即启动setInterval(() ws.send({data:{status:1,audio:}}), 30000)注意必须是合法 JSON 字符串不能是空对象{}现象识别结果中大量出现「嗯」「啊」「呃」等语气词且标点混乱如「你好吗。」原因business.dwa设为wpgs但vad_eos设置过大5000ms导致服务端误将停顿识别为句末解决vad_eos改为2500同时在business中增加punc: cn强制中文标点现象Chrome 下正常Safari 报DOMException: The AudioContext was not allowed to start原因Safari 要求AudioContext必须在用户手势click/touch回调中启动解决startCapture函数必须绑定在 button 的click上且不能包裹在setTimeout或 Promise.then 中异步调用现象识别文字延迟高达 3–5 秒且偶发重复识别同一句话出现两次原因captureFrame()调用频率过高40ms导致AnalyserNode数据未刷新反复发送旧帧解决严格使用setInterval控制 40ms 间隔禁用requestAnimationFrame若需更高精度必须升级到AudioWorklet现象用户切换浏览器标签页后回来识别完全失效analyser.getFloatTimeDomainData返回全 0原因Chrome 在后台标签页会暂停AudioContext定时器AnalyserNode停止更新解决监听document.visibilityState切后台时stopCapture()切回前台时重新startCapture()5. 生产环境加固后端代理鉴权、错误降级与离线缓存策略把api_secret硬编码在前端是自杀行为。正式上线必须走后端代理前端向自己服务器请求 token服务器用api_secret签名后返回给前端全程api_secret不暴露。但代理层引入了新问题——token 过期、网络超时、代理服务宕机。这时不能让用户看到「连接失败」就放弃而应提供降级路径。5.1 后端代理接口设计Node.js Express 示例// server/routes/xunfei.js const express require(express); const router express.Router(); const crypto require(crypto); const APP_ID process.env.XUNFEI_APP_ID; const API_KEY process.env.XUNFEI_API_KEY; const API_SECRET process.env.XUNFEI_API_SECRET; router.get(/token, (req, res) { try { const date new Date().toGMTString(); const signatureOrigin host: ws-api.xfyun.cn\ndate: ${date}\nGET /v2/iat HTTP/1.1; const signature crypto.createHmac(sha256, API_SECRET) .update(signatureOrigin, utf-8) .digest(base64); const authorization Buffer.from( api_key${API_KEY}, algorithmhmac-sha256, headershost date request-line, signature${signature} ).toString(base64); const url wss://ws-api.xfyun.cn/v2/iat?authorization${encodeURIComponent(authorization)}date${encodeURIComponent(date)}hostws-api.xfyun.cn; res.json({ success: true, wsUrl: url, expiresAt: Date.now() 5 * 60 * 1000 }); } catch (err) { console.error(❌ Token generation failed:, err); res.status(500).json({ success: false, message: Server error }); } }); module.exports router;前端调用方式// 在 Vue 组件中 const fetchWsUrl async () { try { const res await fetch(/api/xunfei/token); const { wsUrl, expiresAt } await res.json(); return { wsUrl, expiresAt }; } catch (err) { console.error(⚠️ Failed to fetch token from proxy:, err); // 触发降级启用本地 mock 模式见下节 return { wsUrl: null, expiresAt: 0 }; } };5.2 降级策略Mock 模式与 localStorage 缓存当后端代理不可用时启用「Mock 模式」不连接讯飞而是用 Web Speech APIChrome 仅支持或预置关键词库做简易识别。同时将最近 5 次有效wsUrl缓存到localStorage并在过期前 30 秒自动刷新// src/utils/xunfei-cache.js export const XunfeiCache { get() { const cached localStorage.getItem(xunfei_ws_cache); if (!cached) return null; const { url, expiresAt } JSON.parse(cached); return Date.now() expiresAt - 30000 ? { url, expiresAt } : null; }, set(url, expiresAt) { localStorage.setItem(xunfei_ws_cache, JSON.stringify({ url, expiresAt })); }, clear() { localStorage.removeItem(xunfei_ws_cache); } }; // 在组件中 const initConnection async () { let { wsUrl, expiresAt } XunfeiCache.get() || {}; if (!wsUrl) { const fresh await fetchWsUrl(); if (fresh.wsUrl) { XunfeiCache.set(fresh.wsUrl, fresh.expiresAt); wsUrl fresh.wsUrl; } else { // 启用 Mock 模式 enableMockMode(); return; } } // 正常连接逻辑... };5.3 离线可用性增强Service Worker 预缓存核心资源即使网络中断也要保证 UI 可用。用 Service Worker 缓存 Vue 组件、音频处理工具类、基础样式// public/sw.js const CACHE_NAME xunfei-stt-v1; const urlsToCache [ /, /index.html, /assets/index.[hash].js, /assets/index.[hash].css, /utils/audio-capture.js, /components/XunfeiSTT.vue ]; self.addEventListener(install, event { event.waitUntil( caches.open(CACHE_NAME) .then(cache cache.addAll(urlsToCache)) ); }); self.addEventListener(fetch, event { event.respondWith( caches.match(event.request) .then(response response || fetch(event.request)) ); });提示Service Worker 只在 HTTPS 下生效localhost 除外。上线前务必确认你的域名已配好 SSL 证书否则navigator.serviceWorker.register()会静默失败。6. 验证与调优用 Chrome DevTools 定位音频流瓶颈与 WebSocket 性能拐点真正决定识别质量的从来不是算法本身而是你能否把每一帧音频准时、无损、低延迟地送到讯飞服务器。这需要你像调试网络请求一样用 Chrome DevTools 的Performance和Network面板做深度分析。下面是我给某在线教育平台做的三次压测对比结论直接写进上线 checklist。6.1 三阶段性能验证法阶段目标关键指标达标阈值工具定位方法采集阶段验证AudioContext数据获取是否稳定captureFrame()执行耗时分布95% 帧 ≤ 8msPerformance → Record → Filteraudio-capture.js传输阶段验证 WebSocket 发送是否及时ws.send()调用间隔标准差≤ 3msNetwork → WS → 查看每帧发送时间戳端到端延迟从张嘴到收到首字识别结果speechStart → firstWordReceived≤ 1200ms自定义 performance.mark() 打点具体操作在captureFrame()开头加performance.mark(capture-start)结尾加performance.mark(capture-end)在ws.send()前加performance.mark(send-start)在onmessage解析出首个字时加performance.mark(first-word)最后用performance.measure(end-to-end, capture-start, first-word)计算总耗时。6.2 常见性能拐点与调优参数表问题现象根本原因调优动作预期收益captureFrame()耗时突增至 15msAnalyserNodefftSize 过大如 2048将fftSize从 2048 改为 1024耗时下降 40%CPU 占用降低 22%WebSocket 发送间隔抖动 10mssetInterval被主线程阻塞如长列表渲染将发送逻辑移至Web Worker抖动降至 ≤2ms丢帧率归零端到端延迟 1800msvad_eos过大 未开启dwavad_eos2500dwawpgs首字延迟稳定在 900–1100ms移动端识别率骤降 30%iOS Safari 未处理AudioContext暂停恢复监听visibilitychangewebkitAudioContext.state识别率回升至桌面端 98% 水平6.3 我的上线前必做清单已验证 12 个项目强制走一遍「切后台→切回→继续识别」流程Safari 和部分安卓 WebView 会 kill AudioContext不测试等于没上线用 Chrome 的chrome://flags/#unsafely-treat-insecure-origin-as-secure临时开启 localhost HTTPS 模拟Service Worker 和AudioContext在非 HTTPS 下行为差异极大在低端安卓机如红米 Note 7上连续识别 10 分钟观察内存是否泄漏performance.memory持续增长 50MB 就要查AudioContext未 close用 Charles 抓包验证所有 WebSocket 帧是否含status字段且值为 0/1/2少一个status0初始化帧整条流就废把vad_eos从 2500 改成 1000录一段带停顿的会议录音确认服务端是否误切句如「我们先看第一部分……停顿 0.8s第二部分」被切成两段。从那以后我每次上线语音功能都强制走一遍这五步——哪怕 PM 催得再急我也宁愿晚半天不交一个在红米手机上录 3 分钟就 crash 的版本。因为用户不会管你是用了什么高大上的算法他只记得「那个字幕功能点了三次都没反应」。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站