☰
Vue实时语音转文字实战:WebSocket+讯飞API实现前端字幕
2026/10/21 1:33:00 网站建设 项目流程

简介:面向需要在前端项目中接入实时语音转写能力的开发者,这份资源是基于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。为什么必须重采样?讯飞实时转写接口对音频格式有硬性要求,format=audio/L16;rate=16000,也就是 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字段标记三个阶段:第一帧status=0,中间帧status=1,最后一帧status=2。这个项目把状态机封装成了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 单声道,支持停止时 flush
src/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}", algorithm="hmac-sha256", headers="host 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;rate=16000', 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。status=0的帧同时携带common和business参数,相当于向服务端注册一个识别任务;中间帧只带data;最后一帧status=2表示音频流结束,服务端收到后会返回最终识别结果并关闭连接。

参数说明: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_URL=wss://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 秒也会触发服务端关闭。

解决:把“音频停止”和“连接关闭”解耦。用户点击停止时,先发送status=2的最终帧,在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 状态,先发status=2再关闭连接。这套收尾代码必须写,否则开发调试时路由来回切换几次,麦克风就被占满,后续再进页面会直接拿不到设备。

6. 二次开发指南:加 VAD 静音检测、热词表和 Electron 封装

6.1 加本地 VAD静音检测,省流量也省时间

实时转写是按时长计费的,长期不说话还开着连接就是浪费。常见做法是在前端做轻量 VAD:计算每一帧的 RMS 能量,低于阈值且持续超时,就自动发送status=2结束会话。

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 逻辑。这套项目分析方式帮我少踩了无数坑,希望也能帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询