资讯详情

资讯详情

Vue实时语音转文字实战:WebSocket+讯飞API实现前端字幕

简介面向需要在前端项目中接入实时语音转写能力的开发者这份资源是基于Vue框架的科大讯飞语音转写集成示例。它实现了录音采集、音频流持续上传、识别结果动态展示的完整链路并包含请求签名、错误提示等关键细节处理。压缩包内共有八个文件其中七个为脚本文件覆盖接口调用封装、音频处理线程模块以及加密签名所需的工具库另有一个页面文件作为演示入口整体体积仅十六千字节结构紧凑。目前已有四千四百三十人学习下载适合具备基础前端知识、希望快速掌握讯飞语音识别集成要点的开发者。通过该示例可复用现成的录音与流式处理脚本理解浏览器获取麦克风、工作线程处理音频、服务接口返回文本的配合方式从而缩短开发调试时间。1. 打开这个 Vue 语音转文字项目十分钟从“装好依赖”到“实时字幕”第一次跑通这个项目时我盯着屏幕上的中文一句句蹦出来第一反应是“就这么简单”。没有后端服务、没有复杂的音频网关一个纯 Vue 前端页面靠浏览器麦克风权限和一条 WebSocket 长连接就把实时语音转文字做成了。说白了它不是在本地做识别而是把麦克风采到的 PCM 音频切成小块推给讯飞开放平台的实时语音转写 WebAPI再把返回的识别文本增量拼成字幕。适合谁想快速做会议纪要、直播字幕、课堂板书转写的前端开发者尤其是已经在用 vue.js 技术栈、不想为一个小功能单独起 Java 或 Node 后端的人。这套实现把“录音、鉴权、发帧、收结果”全封装在了前端你拿到手改改钥匙就能跑。2. 项目功能与技术选型WebSocket 长连接 AudioContext 采集的双链路架构2.1 功能清单不只是“能转文字”先说功能面。这个项目打开页面后主要做四件事点击开始录音、实时显示识别中间结果、点击停止并输出最终文本、清空重来。界面不花哨但状态机是完整的——空闲、录音中、识别中、出错、连接断开每种状态都有对应的按钮灰度和提示文案。第二层功能是识别参数。它预设了中文普通话识别采样率 16k、单声道 PCM这是讯飞 WebAPI 性价比最高的配置。你可以在页面上切换“标点预测”开关打开后识别结果会带上句号、逗号、问号适合会议纪要场景关掉则输出纯文字流适合字幕逐字显示。有些版本还带了音量波形条本质上是把 AudioContext 的 AnalyserNode 接了出来方便判断麦克风有没有在工作。第三层是“断线重连”。实时转写最怕 WebSocket 中途断开这套实现里 Socket 的 onclose 会触发计数连续断开三次就提示用户手动重连而不是无限自动重试。这个设计很实在因为识别过程中服务端会因为静音超时主动断开无限重试反而会把错误放大。2.2 技术栈为什么是 Vue 3 Web API 这套组合项目基于 Vue 3 Vite 构建状态管理用的是 Composition API 的 ref/reactive没有额外引入 Pinia。这个选择很聪明识别状态机只涉及一个页面内几个布尔值和字符串强行上全局状态管理反而是负担。音频采集走的是 Web Audio API 的 getUserMedia AudioContext重点在拿到 audio 轨道后把浏览器默认的 48k 采样率重采样到 16k。为什么必须重采样讯飞实时转写接口对音频格式有硬性要求formataudio/L16;rate16000也就是 16k 的线性 PCM。浏览器麦克风通常给 48k 浮点数据直接推上去识别率会肉眼可见地崩。网络通信走原生 WebSocket不是 axios。因为实时转写是“边录边发”的流式交互HTTP 请求天然不合适。关键点在于鉴权 URL 的组装——WebSocket 连接地址上要带 HMAC-SHA256 签名参数这个后面会拆代码。2.3 整体架构一条音频数据的双向通道整个项目只有两条链路。上行链路麦克风 → AudioContext 采样 → 16bit PCM 编码 → base64 → 按帧切分 → WebSocket.send。下行链路WebSocket.onmessage → JSON 解析 → 提取data.result.ws数组 → 拼接成增量文本 → 渲染到页面。中间夹着一个重要的模块叫“帧状态机”。讯飞协议要求发送音频数据时用status字段标记三个阶段第一帧status0中间帧status1最后一帧status2。这个项目把状态机封装成了sendAudioChunk(status, base64Data)一个函数上层录音逻辑只管切数据不用关心协议细节。另外还有一条隐性链路定时器。录音开启后每 40ms 切一次音频 buffer。为什么是 40ms因为讯飞官方建议每帧音频时长在 40ms 到 80ms 之间太短会因网络开销浪费带宽太长会导致服务端等待累积、识别延迟明显。40ms 算是实践下来体感最均衡的值。3. 核心模块拆解从麦克风 PCM 到讯飞 WebAPI 返回文本的完整链路3.1 目录结构看完目录就知道项目的边界在哪解压项目后目录结构大致如下├── index.html ├── package.json ├── vite.config.js ├── .env.development ├── .env.production ├── src/ │ ├── main.js │ ├── App.vue │ ├── api/ │ │ ├── iatAuth.js # 讯飞 WebAPI 鉴权 URL 组装 │ │ └── iatSocket.js # WebSocket 连接、发帧、收帧封装 │ ├── audio/ │ │ ├── recorder.js # getUserMedia AudioContext 采集 │ │ └── pcmEncoder.js # 浮点样本转 16bit PCM base64 │ ├── components/ │ │ ├── RecordButton.vue # 开始/停止按钮与状态联动 │ │ └── ResultPanel.vue # 实时文本展示区 │ └── views/ │ └── SpeechToTextView.vue # 主页面串起整套流程各目录职责见下表路径职责关键点src/api/iatAuth.js生成带签名的 WebSocket URL不在这里发起请求只负责签名src/api/iatSocket.js管理连接生命周期发送队列、断线重连、消息分发src/audio/recorder.js采集与重采样16k 单声道支持停止时 flushsrc/audio/pcmEncoder.js数据编码浮点转 16bit注意大小端src/views/SpeechToTextView.vue流程编排按钮状态机 结果拼接这个目录边界很清楚音频处理和协议处理完全隔离UI 不直接碰 Socket。你做二次开发时想换录音引擎、想改协议版本都不用在组件里翻代码。3.2 鉴权模块WebSocket 地址上的 HMAC-SHA256 签名实时语音转写的第一步不是打开麦克风而是拼一个带签名的 WebSocket URL。讯飞 WebAPI 要求对请求做 HMAC-SHA256 签名把签名放在 URL 的 query 参数里iatAuth.js干的就是这件事。const crypto require(crypto) // 对字符串做 RFC3986 编码签名算法要求严格区分大小写和转义 function rfc3986Encode(str) { return encodeURIComponent(str) .replace(/!/g, %21) .replace(//g, %27) .replace(/\(/g, %28) .replace(/\)/g, %29) .replace(/\*/g, %2A) } function buildAuthUrl({ appId, apiKey, apiSecret }) { const host iat-api.xfyun.cn const path /v2/iat const date new Date().toGMTString() // 签名原文一共三行host、date、请求行 const signatureOrigin host: ${host}\ndate: ${date}\nGET ${path} HTTP/1.1 const signature crypto .createHmac(sha256, apiSecret) .update(signatureOrigin) .digest(base64) // authorization 是拼好的凭证串整体再放进 URL const authorization api_key${apiKey}, algorithmhmac-sha256, headershost date request-line, signature${signature} const url wss://${host}${path}?authorization${rfc3986Encode(authorization)}date${rfc3986Encode(date)}host${host} return url }这段代码有三个容易写错的地方。第一签名原文必须是host: domain、date: GMT时间、GET /v2/iat HTTP/1.1三行顺序不能乱换行符用\n。第二signature是用apiSecret做 HMAC-SHA256不是用apiKey我第一次接的时候拿反了结果一直报鉴权失败。第三拼好的authorization必须整体做一次 URL 编码再放进 query否则 WebSocket 握手会因为参数里的引号、等号解析错乱而失败。参数说明appId、apiKey、apiSecret三个值都来自讯飞控制台的“实时语音转写”服务其中apiSecret等同于密码不要提交到 Git。项目里通过.env文件注入由vite.config.js的loadEnv读取运行时打进import.meta.env这样代码里不会出现明文密钥。3.3 音频采集与编码从 Float32 到 16bit PCM 的必经之路录音模块是最容易“看起来在录但识别不出来”的部分。核心原因就是格式不匹配。下面是简化后的录音器实现async function initRecorder() { const stream await navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, sampleRate: 16000, echoCancellation: true, noiseSuppression: true } }) const ctx new AudioContext({ sampleRate: 16000 }) const source ctx.createMediaStreamSource(stream) const processor ctx.createScriptProcessor(4096, 1, 1) source.connect(processor) processor.connect(ctx.destination) processor.onaudioprocess (event) { const input event.inputBuffer.getChannelData(0) // Float32 数组 const pcm encodePCM(input) // 转成 16bit 小端整数 Buffer onAudioData(pcm) // 由外层切帧发送 } return { ctx, processor, stream } } function encodePCM(float32Array) { const buffer new ArrayBuffer(float32Array.length * 2) const view new DataView(buffer) for (let i 0; i float32Array.length; i) { let s Math.max(-1, Math.min(1, float32Array[i])) view.setInt16(i * 2, s 0 ? s * 0x8000 : s * 0x7fff, true) } return new Uint8Array(buffer) }逻辑说明getUserMedia拿到的是浏览器底层音频流AudioContext负责把它变成可处理的数据块。ScriptProcessor每次回调给出一段Float32样本范围在 -1 到 1 之间。encodePCM做的事情是范围钳制、放大到 16bit 整数范围、按小端序写入二进制最后转成Uint8Array交给发送层。参数说明createScriptProcessor(4096, 1, 1)的 4096 是缓冲区大小约合 256ms 音频。AudioContext({ sampleRate: 16000 })强制浏览器重采样到 16k这样后端不挑格式。需要提醒一点ScriptProcessor在部分浏览器里已被标记为废弃更现代的做法是换成AudioWorklet但这个项目的采集体量用ScriptProcessor完全够而且兼容性反而更好。echoCancellation和noiseSuppression建议保留为true会议室录音时能明显减少回声导致的误识别。3.4 发送与接收状态机驱动的流式协议音频数据准备好后进入iatSocket.js的发送模块。这里最核心的是帧状态机。class IatSocket { constructor({ appId, apiKey, apiSecret }) { this.url buildAuthUrl({ appId, apiKey, apiSecret }) this.queue [] // 待发送的音频帧 this.isSending false } connect() { this.ws new WebSocket(this.url) this.ws.onmessage (e) this.handleMessage(JSON.parse(e.data)) } sendAudioChunk(status, audioBase64) { const frame { common: { app_id: this.appId }, business: { language: zh_cn, domain: iat, accent: mandarin, vad_eos: 5000, ptt: 1 }, data: { status, // 0第一帧 1中间帧 2最后一帧 format: audio/L16;rate16000, encoding: raw, audio: audioBase64 } } // 用队列串行发送避免 Socket 缓冲被撑爆 this.queue.push(frame) this.flushQueue() } flushQueue() { if (this.isSending || this.ws.readyState ! WebSocket.OPEN) return this.isSending true const frame this.queue.shift() this.ws.send(JSON.stringify(frame)) this.isSending false if (this.queue.length 0) this.flushQueue() } handleMessage(msg) { if (msg.code ! 0) { console.error(识别错误 ${msg.code}: ${msg.message}) return } const wsList msg.data?.result?.ws || [] const sentence wsList .map(wsItem wsItem.cw) .flat() .map(cw cw.w) .join() this.onText(sentence) // 外部监听做增量拼装 } }逻辑说明每次录音回调产生一段 PCM发送层把它 base64 后按“第一帧、中间帧、最后一帧”的语义包装成 JSON。status0的帧同时携带common和business参数相当于向服务端注册一个识别任务中间帧只带data最后一帧status2表示音频流结束服务端收到后会返回最终识别结果并关闭连接。参数说明vad_eos: 5000表示静音 5 秒后服务端自动断开这个值可以根据场景调——会议发言通常 3~5 秒直播连麦可以放宽到 8 秒。ptt: 1开启标点预测0则关闭。accent: mandarin是指普通话讯飞还支持cantonese等方言改这个字段就能换方言识别。接收端的拼接逻辑很简单data.result.ws里每个cw节点就是一个词或标点把当前帧的所有cw拼起来就是本帧的增量文本外部把增量追加到总结果后面即可。4. 运行与配置密钥、环境、启动命令一手落4.1 环境准备项目运行依赖 Node.js 和 npm/yarn/pnpm 其中一个包管理器。推荐 Node 保持 LTS 版本太旧的版本跑 Vite 会报语法错误。没有特殊系统要求Windows、macOS、Linux 都能跑但有一点要特别注意浏览器要求 getUserMedia 必须在安全上下文中使用也就是 HTTPS 或 localhost。局域网 IP 访问时Chrome 会直接禁止麦克风权限。建议开发阶段统一用npm run dev打开本地服务浏览器地址栏走localhost而不是127.0.0.1避免某些系统下证书策略不一致导致麦克风权限弹窗不出现。4.2 配置讯飞开放平台密钥第一次运行最关键的配置就是这个。前往讯飞开放平台创建“实时语音转写”应用拿到三个值APP_ID、API_KEY、API_SECRET。然后打开项目根目录的两个文件# .env.development VITE_APP_ID你的appId VITE_API_KEY你的apiKey VITE_API_SECRET你的apiSecret VITE_IAT_WS_URLwss://iat-api.xfyun.cn/v2/iat# .env.production VITE_APP_ID你的appId VITE_API_KEY你的apiKey VITE_API_SECRET你的apiSecret参数说明Vite 约定环境变量必须以VITE_开头才会被打进前端代码。.env.development是本地开发配置.env.production是打包配置。密钥放在前端环境变量里只是权宜之计它仍然会暴露在浏览器 Network 面板里。如果项目要上线给别人用正确做法是在后端用 Node 代理签名把apiSecret藏在服务端。二次开发章节我会给改造思路。4.3 安装依赖与启动命令# 安装依赖 npm install # 本地开发 npm run dev # 生产构建 npm run build # 预览构建产物 npm run preview跑起来之后访问控制台输出的本地地址页面会看到一个麦克风按钮。点击后浏览器弹权限框允许后说话右侧文本区应该会出现识别结果。如果点了按钮但没有任何反应按下 F12 看 Console权限被拒会提示NotAllowedError签名有问题会提示 WebSocket 连接失败或握手 401音频格式不对不会报错但你会一直看不到任何输出。出现“按钮变红但文本区空白”时我一般先点开控制台 Network 面板看 WebSocket 帧如果每 40ms 有绿色的opcode 2帧发出去说明录音和发送链路是通的如果只发了几帧就停住问题几乎都出在 AudioContext 没有正确 resume 上——浏览器要求用户手势后才能开始音频处理。5. 常见问题排查鉴权失败、无声音、断连重连的六条踩坑记录5.1 WebSocket 连接直接被拒绝状态码 401现象F12 控制台提示WebSocket connection failed或者服务端返回 401。原因绝大多数情况是apiSecret复制错了或者apiKey和apiSecret填反了。其次是签名 URL 里的date参数和请求头发送的date不一致注意项目里生成date用的是new Date().toGMTString()如果你手动篡改了它签名校验必然失败。解决先用一个最小 Node 脚本单独打印buildAuthUrl的产物拿到 URL 后手动去掉wss://换成https://在浏览器里访问一次看服务端返回的错误码。讯飞的错误码很明确通常会直接告诉你“invalid signature”还是“api_key not found”。5.2 录音开始了、麦克风灯亮但不出文字现象录音按钮正常变红麦克风图标在浏览器标签栏常亮但识别区一个字都没有。这问题最容易让人怀疑人生因为“看着一切正常”。原因八成是音频格式不匹配。常见于 AudioContext 创建失败后回退到了默认采样率 48k而你依然按 16k 的format打包发送。服务端按 16k 解析 48k 数据出来的全是噪声自然一个字也不给。另一个可能性是encodePCM写成了大端序大部分 x86 机器要求小端序数据反了同样识别不出来。解决在onaudioprocess回调里加一行console.log(ctx.sampleRate)确认实际采样率是 16000。如果是 48000检查new AudioContext({ sampleRate: 16000 })有没有被 try/catch 吞掉。大小端问题可以把setInt16(i * 2, value, true)的第三个参数改为false对比测试但正常项目不应该动这个值。5.3 识别到一半连接自动断开前端疯狂报错现象说话连续超过 20 秒后服务端断开连接之后所有发送操作都抛WebSocket is not open或类似错误。原因讯飞实时转写的单次会话有最长时长限制连续音频流超过限制会主动断开。另外vad_eos: 5000表示静音 5 秒也会触发服务端关闭。解决把“音频停止”和“连接关闭”解耦。用户点击停止时先发送status2的最终帧在onmessage里收到最终识别结果后再close()连接而不是点击停止就直接断开 Socket。这样能保证最后一帧被正确处理。如果想长时间录音就需要分段续传——每 60 秒关一个会话、开一个新会话然后把多段文本拼在一起。5.4 页面在移动端或某些浏览器上点按钮没反应现象Chrome 桌面端正常但微信内置浏览器或部分安卓浏览器里点击录音按钮后没有任何动作控制台报getUserMedia is not defined。原因实时语音转写强依赖navigator.mediaDevices微信内置浏览器和过旧的 WebView 不完整支持 Web Audio 与 getUserMedia。此外手机浏览器对 HTTPS 的要求更严格本地调试用 HTTP 访问必然失败。解决项目定位是桌面端工具时在getUserMedia调用前加能力检测不支持就弹友好提示。如果要在移动端跑必须把所有页面都挂到 HTTPS 域名下不能只依赖 localhost。5.5 识别文本没有标点一大段话糊在一起现象识别内容都对了但全是空格分隔的词没有句号、逗号看起来像“关键词列表”而非完整句子。原因business.ptt被设为了0标点预测功能没开。解决检查发送帧里business字段确认ptt: 1。要注意的是开启标点预测会稍微增加服务端处理延迟字幕场景如果觉得跟嘴速度不够可以关掉折中。5.6 切换页面或关闭组件后麦克风还在收音现象路由跳转后标签栏的麦克风图标依然常亮后台还在发音频帧页面卡顿愈发明显。原因组件销毁时没有回收AudioContext、没有关闭WebSocket、没有释放MediaStreamTrack浏览器底层录音通道一直占着。解决在 Vue 的onBeforeUnmount钩子里做三件事遍历stream.getTracks()调track.stop()调用audioContext.close()如果 Socket 还在 OPEN 状态先发status2再关闭连接。这套收尾代码必须写否则开发调试时路由来回切换几次麦克风就被占满后续再进页面会直接拿不到设备。6. 二次开发指南加 VAD 静音检测、热词表和 Electron 封装6.1 加本地 VAD静音检测省流量也省时间实时转写是按时长计费的长期不说话还开着连接就是浪费。常见做法是在前端做轻量 VAD计算每一帧的 RMS 能量低于阈值且持续超时就自动发送status2结束会话。function computeRMS(float32Array) { let sum 0 for (let i 0; i float32Array.length; i) { sum float32Array[i] * float32Array[i] } return Math.sqrt(sum / float32Array.length) }在onaudioprocess里拿到computeRMS后小于 0.01 就让静音计数累加静音超过 1200ms 就触发停止。阈值 0.01 是经验值安静办公室可以调到 0.008有空调噪音的环境建议调到 0.02否则容易误切。6.2 热词表提升专业词汇识别率做会议纪要时人名、产品名、专业术语经常识别错。讯飞 WebAPI 支持在business参数里带热词表常见写法是加cw字段business: { language: zh_cn, domain: iat, accent: mandarin, ptt: 1, cw: [ { w: Vite }, { w: Pinia }, { w: WebSocket } ] }参数说明cw是数组每个元素里w就是要强化的词。热词表对专有名词的提升非常明显我拿“WebSocket”这个词做过对比不加时经常识别成“web 插口”加完热词后基本能稳定出正确文本。注意热词表只对当前会话有效需要长期生效就做成页面配置项由用户在界面里填写后用 JSON.parse 转成数组再合进business。6.3 把密钥藏到 Electron 主进程里项目目前是纯前端apiSecret会在生产包中暴露。要上线给外部用户用建议把这套东西封装成 Electron 桌面应用密钥放主进程渲染进程通过 IPC 调签名接口。改造思路不难主进程里维护一个buildAuthUrl函数用ipcMain.handle(build-iat-url)暴露给渲染进程。渲染进程的iatSocket.js不再读取import.meta.env而是await window.electronAPI.buildIatUrl()拿地址。这样即使有人解包 asar 文件也看不到真实密钥。6.4 从接到这个项目开始我的固定动作自从拆过这类第三方 WebAPI 项目我每次拿到新资源的第一件事都是先看.env文件需要哪些字段再看Socket的onmessage有没有把所有分支都处理掉。因为这套流程里签名错了是静默失败、音频格式错了是静默失败、Socket 断了更是静默失败——前端不会给你一个明确的红屏你只能靠逐步打日志定位。从那以后我只要接实时语音类服务都会强制走一遍“密钥可读、签名 URL 能拼出来、WebSocket 能连上、音频帧格式和服务端要求逐字对齐”这四步确认后再动 UI 逻辑。这套项目分析方式帮我少踩了无数坑希望也能帮到你。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

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

立即咨询 →