简介科大讯飞实时语音转文字Vue.js版本是一份面向前端开发者的集成示例项目基于Vue.js调用科大讯飞WebAPI覆盖会议记录、在线教育、直播互动等实时语音转写场景。资源包共8个文件含7个JavaScript脚本与1个HTML入口页面RAR压缩包仅16KBJS文件包括Vue相关UMD构建、音频采集与流式处理所需的Worker/Worklet脚本以及调用讯飞接口时进行鉴权签名的MD5、HmacSHA1、HmacSHA256加密模块HTML页面则用于结果展示与交互控制。该资源已有4430人学习或下载适合具备Vue.js基础、想快速实现语音识别功能的开发者。通过这份示例可以直观理解如何在组件中获取麦克风权限、持续上传音频数据并动态更新识别结果同时掌握Web Workers避免阻塞、状态管理与异常处理等关键细节项目中的签名算法与音频处理逻辑均可直接复用能显著减少从零搭建的前期成本。1. 在 Vue 项目里做实时语音转文字先把 WebAPI 的套路摸透在 Vue 项目里接实时语音转写最省事的路径是直接用某语音服务商的 WebAPI把录音流实时推上去、再把识别结果拉回页面。很多开发者以为这类接口是“发一段音频返回一段文字”真正跑起来才发现它是 WebSocket 双向流式传输鉴权参数要自己算、音频格式要做重采样、结果字段得逐层解析。这篇笔记就围绕一个 Vue.js 版的实时语音转文字 WebAPI 项目展开把它拆成能落地的实现步骤鉴权怎么拼 URL、PCM 帧怎么送、返回的 JSON 怎么读、踩坑点在哪。适合已经会用 Vue 基础语法、想给页面加语音能力的开发者也适合刚拿到这套资源不知道怎么下手的同学照着复现。2. 先看懂它的调用机制鉴权、帧推送与结果回调2.1 WebSocket 双向流式接口为什么选它而不是 REST 上传实时语音转写和离线转写最大的区别在于传输方式。离线接口通常是 REST 上传一整段音频服务端转完把文本一次性返回实时接口则需要边说话边返回结果这样才能在页面上做出“你说了什么文字立刻跟出来”的交互。所以某语音服务商的实时转写 WebAPI 对外提供的是 wss 地址客户端和服务端之间保持一条长连接通过它持续发送二进制音频帧同时持续接收 JSON 格式的识别结果。选择 WebSocket 而不是 HTTP 轮询主要考量是延迟和流量。如果每 100ms 轮询一次服务端拿结果请求头会吃掉大量带宽如果攒音频到说完再上传又失去了“实时”的意义。WebSocket 的长连接是双向的音频帧可以按固定节奏推上去服务端的结果也可以主动推下来一次握手之后不需要反复建立连接。这套机制在语音输入、会议转写、字幕实时生成这类场景里几乎是标配。要明确一点这个接口不是把录音文件丢上去就完事。它期望你按固定间隔常见的是每 40ms 或 60ms发送一个音频 chunk然后服务端把这段时间内识别出的临时结果partial和最终结果final推回来。整个链路里客户端对音频的切分节奏直接决定识别质量和回调频率。2.2 签名与鉴权URL 上那三个参数是怎么算出来的WebAPI 的连接地址不是写死的 wss 字符串而是需要拼接鉴权参数。常见做法是生成三个参数date、authorization、signature把它们按指定顺序拼在 wss URL 的查询串里。生成签名用 HMAC-SHA256密钥是服务商下发的 SecretKey拼接的字符串是“用 GET 方法访问 /v2/iat 的请求行、header 字段等按规则组合后的结果”。计算签名的核心逻辑可以抽象成一段 JavaScript 函数放到 Vue 项目的工具文件里import CryptoJS from crypto-js function buildAuthUrl(host, path, apiKey, apiSecret) { // date 必须是 UTC 时间格式固定为 RFC1123 const date new Date().toUTCString() // signature_origin 是待签名字符串由 host、date、请求行拼接而成 const signatureOrigin host: ${host}\ndate: ${date}\nGET ${path} HTTP/1.1 // 用 SecretKey 对原始字符串做 HMAC-SHA256再 Base64 编码 const signature CryptoJS.enc.Base64.stringify( CryptoJS.HmacSHA256(signatureOrigin, apiSecret) ) // authorization 是把鉴权信息 Base64 编码后的结果 const authorizationOrigin api_key${apiKey}, algorithmhmac-sha256, headershost date request-line, signature${signature} const authorization btoa(authorizationOrigin) // 拼接成完整 wss URL return wss://${host}${path}?authorization${encodeURIComponent(authorization)}date${encodeURIComponent(date)}host${host} }这里最容易被忽略的是date不能直接用本地时间必须用 UTC 字符串否则服务端比对时间戳会直接拒绝握手。signatureOrigin里 host、date、GET 行的顺序是固定的多一个空格或少一个换行都会导致鉴权失败。用btoa做 Base64 编码时要注意非 ASCII 字符一般 authorization 原始串都带英文引号和逗号直接用 btoa 没问题但更稳妥的做法是用 encodeURIComponent 先处理再拼接。实际项目中我一般不会在前端写死 APIKey 和 SecretKey原因在后面第 5 章展开但如果做本地 Demo这段函数可以直接搬到 Vue 的 utils 目录里把 host、path、apiKey、apiSecret 四个参数配置到环境变量中。host 一般是iat-api.xfyun.cn这类地址path 在 v2 版本是/v2/iat我建议不要把 host 和 path 写进代码里它们会随服务商版本升级而变动。2.3 音频帧格式与结果 JSON 的字段怎么读服务端要求上传的音频是 16kHz 采样率、16bit 量化的单声道 PCM 原始数据。这意味着浏览器里 getUserMedia 拿到的 AudioContext 默认采样率通常 48kHz必须先重采样到 16kHz再按帧切分后发送。字节序是小端Little Endian每个采样点占两个字节。如果传了 48kHz 的数据服务端不会直接报错但识别结果会出现大量错字和漏字这个坑很多人在第一次联调时踩到。结果 JSON 的层级比较深以 v2 接口为例核心数据在data.cn.st.rt数组里rt 是识别结果的数组最后一项的cw数组装着词组和标点。下面是一段常见的解析逻辑function parseIatResult(json) { // 服务端消息带 action 字段result 表示识别结果error 表示出错 if (json.action result) { const rt json.data.cn.st.rt let text // 一个 rt 里多个词条顺序拼接 rt.forEach(item { item.cw.forEach(word { text word.w }) }) return { type: json.data.cn.st.type, // 0 临时结果1 最终结果 text } } return null }type字段是 0 还是 1直接决定这段文本该用“临时渲染”还是“固定落库”。临时结果会随着后续音频不断被覆盖修正最终结果是这一整句的定稿。很多初学者把临时结果直接存进数据库结果发现一句话在几秒内被改了三四个版本这就是没有区分 type 导致的。解析函数的核心是遍历rt数组时要注意它可能包含多段合并文本的时机要对齐音频切片。另外最终结果出现后要清空当前句子的临时缓冲区否则下一句的临时文本会接在上文后面形成“串句”。3. 在 Vue 3 里搭一个可运行的转写模块录制、发送、渲染3.1 麦克风采集getUserMedia 与 16k 单声道重采样Vue 项目里拿到浏览器麦克风流的标准姿势是调用navigator.mediaDevices.getUserMedia。这个方法返回一个 Promiseresolve 之后得到 MediaStream再从流里取音频轨道交给 AudioContext 处理。需要注意两点一是页面必须是 HTTPS 或 localhost 环境否则浏览器直接拒绝授权二是获取到流之后要把它存下来不能随手丢后面组件卸载时要靠它关停麦克风。真正决定转写质量的不是选哪个录音库而是采样率转换。AudioContext 的默认采样率跟随声卡常见是 48kHz而 WebAPI 要求 16kHz。重采样最简单的方案是用 OfflineAudioContext 或 ScriptProcessorNode脚本处理节点虽然被标记为过时但在兼容性和实现成本上反而是最直接的方式。下面是一个采集与重采样的封装// audioStream 是 getUserMedia 返回的 MediaStream function setupRecorder(audioStream, onAudioChunk) { const audioCtx new AudioContext() // 创建 MediaStreamSource 把浏览器音频流接进来 const source audioCtx.createMediaStreamSource(audioStream) // 创建一个缓冲区按 BlockSize 收集采样点 const scriptNode audioCtx.createScriptProcessor(4096, 1, 1) const sampleRate audioCtx.sampleRate // 通常是 48000 scriptNode.onaudioprocess (event) { const inputData event.inputBuffer.getChannelData(0) // 把 Float32 音频数据重采样为 16kHz 的 Int16Array const downsampled resampleTo16k(inputData, sampleRate) const pcmBuffer floatTo16BitPCM(downsampled) onAudioChunk(pcmBuffer.buffer) } source.connect(scriptNode) scriptNode.connect(audioCtx.destination) // 避免部分浏览器不触发 onaudioprocess return { audioCtx, scriptNode, source } }这里把connect(audioCtx.destination)这一步加上是为了避免在某些浏览器上没有输出连接的 ScriptProcessorNode 不触发采集回调。最常见的做法是让它静音输出或者干脆接一个零增益的 GainNode。重采样函数和 Float32 转 Int16 的函数建议单独放一个文件里便于单元测试。重采样时要处理采样率整数倍关系48k 转 16k 是三步取一点但如果是 44.1k 则需要做插值不能简单丢弃。参数说明createScriptProcessor 的第一个参数是缓冲区大小4096 表示每次回调处理 4096 个采样帧缓冲区越大小延迟越高但 CPU 占用越低。实时识别场景用 4096 比较稳妥追求低延迟可以调到 2048但低端手机上可能出现爆音。3.2 建立 WebSocket 并把 PCM 帧推上去拿到 PCM 帧之后要建立 WebSocket 连接连接的 URL 就是第 2 章里buildAuthUrl生成的 wss 地址。连接建立后先发一条 JSON 消息把音频格式参数告诉服务端然后进入音频帧循环。音频帧的上发节奏要和服务端约定一致一般用 WebSocket 二进制消息发送 ArrayBuffer 或 Blob前端这边用 setInterval 定时从采集缓冲里取数据。这类代码如下async function startRecognition(authUrl, getNextPCMBuffer) { const ws new WebSocket(authUrl) ws.binaryType arraybuffer ws.onopen () { // 告知服务端音频参数 ws.send(JSON.stringify({ common: { app_id: appId }, business: { language: zh_cn, domain: iat, accent: mandarin }, data: { status: 0, format: audio/L16;rate16000, encoding: raw } })) // 开始按固定频率推送 PCM 数据 audioTimer setInterval(() { const chunk getNextPCMBuffer() if (chunk.byteLength 0) { ws.send(chunk) } }, 40) } ws.onmessage (event) { const result parseIatResult(JSON.parse(event.data)) if (result) { // 回调到 Vue 页面渲染层 onRecognitionResult(result) } } return ws }这段代码的开头消息非常关键status: 0表示这是第一帧携带参数部分后续音频数据帧的 status 要按规则递增到 2表示结束。音频数据的编码格式参数format: audio/L16;rate16000要和前面重采样的参数严格一致。每隔 40ms 推一次帧一帧大约 3200 字节8000 个采样点 × 2 字节这是比较典型的实时参数组合。如果推帧频率过高比如 10ms会导致频繁的 IO 空转过低比如 200ms则会让服务端等待超时连接被断开。getNextPCMBuffer的实现逻辑我通常用一个队列采集回调把 PCM 帧 push 进队列定时器从队列头部取数据。这样做的好处是即使定时器抖动也不容易丢数据。要注意队列长度要有上限如果页面切后台或主线程卡顿队列会无限增长导致内存值爆高这时应该丢弃老帧而不是一直堆着。3.3 渲染实时结果临时结果与最终结果的分流在 Vue 组件里渲染识别结果难点不是把文本显示出来而是如何区分“临时结果”和“最终结果”。识别过程中服务端每收到一段音频会返回这个时间段内最有可能的文字随着后续音频到来前面那段文字可能被重新组合。效果就是页面上一个词刚出现下一秒整个字体被替换成了另一版。正确做法是维护两个状态一个partialText用于存放当前正在识别的临时文本一个finalTextList用于存放已经定稿的句子。渲染时把finalTextList.join()加上当前partialText作为页面的识别内容。当收到 type 为 1 的最终结果时把partialText清空并把文本追加到finalTextList。对应 Vue 组合式 API 的写法大概是const finalSentences ref([]) const currentPartial ref() function onRecognitionResult(result) { if (result.type 1) { // 最终结果定稿存入句子列表 if (result.text.trim()) { finalSentences.value.push(currentPartial.value.trim() ? currentPartial.value.trim() : result.text) } currentPartial.value } else { // 临时结果直接覆盖当前临时文本 currentPartial.value result.text } }这段逻辑有几个细节值得展开。最终结果返回时它的文本和当时的临时结果基本一致但存在个别字词的修正所以要优先采用临时结果上屏、最终结果落库的策略。如果直接把最终结果 append 到列表里而临时结果还没清会导致同一句话出现两次。这里我在把最终结果 push 进数组时会先判断 currentPartial 是否为空如果非空说明临时文本还没被覆盖为最终的完整版本需要先清空再 push避免重复。渲染时用计算属性拼最终展示文本const displayedText computed(() { return finalSentences.value.join() currentPartial.value })这个displayedText直接绑定到页面上配合 textarea 或 contenteditable 的动态滚动就是一个完整的“边说边出字”页面。如果识别的是长语音finalSentences 会不断增长记得给列表做虚拟滚动或 max-height 限制否则几千字之后 DOM 渲染明显掉帧。4. 避坑采样率、HTTPS、组件卸载与断线重连4.1 识别结果全是乱码采样率必须是 16kHz现象接口握手成功音频也推上去了但返回的文本要么是毫无规律的字串要么是正确率极低的“猜字”。原因WebAPI 对输入音频的格式有硬性要求16kHz、16bit、单声道、PCM。浏览器默认取到的麦克风音频是 48kHz 的 Float32 数据直接发送后服务端按错误采样率解码音频内容识别结果自然全乱。另一个常见诱因是使用 AudioContext 把数据转成 ArrayBuffer 时没有做 Int16 的 Endianness 转换导致高低字节颠倒。解决严格按 3.1 节的重采样流程走先做采样率转换、再做 Float32 转 Int16最后用小端序输出。如果是 44.1kHz 的声卡不能直接按比例抽点要用线性插值48k 转 16k 正好是 3:1 抽点相对简单。验证方式是在发送前打印 PCM buffer 的字节长度每秒音频 16000 采样点 × 2 字节 32000 字节如果长度翻倍说明采样率没转成功。4.2 录音能开但识别中间断流检查音频帧推送间隔现象前几秒识别正常大概 10 到 20 秒后服务端不再返回任何结果页面看起来像是“卡住”了。原因推送音频帧的间隔和服务端的接收超时设置有冲突。如果两个帧之间的间隔超过服务端阈值常见是 60s服务端会认为流结束或链路异常主动断开或停止识别。也可能推帧的 setInterval 里执行了同步耗时操作比如 JSON.stringify 一个超大对象导致实际推送间隔拉长。解决把 send 操作改成只负责发送、不做重活数据准备好之后直接 ws.send。发送的内容用 ArrayBuffer 而不是 base64 字符串可以减少序列化损耗。给 setInterval 做一个保护如果上一次发送还没完成就跳过本次发送避免帧堆积。连接断掉后要能自动重连重连不能立刻执行要加延迟因为麦克风流还在立刻重连容易重复握手。4.3 页面离开后麦克风还在闪组件销毁时没有清理流现象从语音识别页面切走后浏览器地址栏旁边的麦克风图标仍然显示或者声音继续被采集。原因Vue 组件卸载时只关掉了 WebSocket没有调用 MediaStreamTrack.stop() 停掉底层音频轨道也没有断开 AudioContext。浏览器只要还有激活的 track麦克风就会继续保持工作状态。解决在 onUnmounted / beforeUnmount 生命周期里做好清理顺序是先停发送定时器、再关闭 WebSocket、接着停掉 ScriptProcessor 和媒体轨道最后 close AudioContext。下面是清理函数的参考写法function cleanupMedia() { clearInterval(audioTimer) ws.close() scriptNode.disconnect() source.disconnect() audioStream.getTracks().forEach(track track.stop()) audioCtx.close() }这里要特别留意audioCtx.close()的时机如果它先执行scriptNode 的 onaudioprocess 可能还会触发一次此时再访问 ctx 内部的 buffer 会抛异常。所以我习惯的执行顺序是先 clearInterval再断开 ws之后断开节点最后停 track。清理逻辑在整个组件的多个退出路径路由跳转、关闭按钮、错误处理里都要调用条件允许就抽成一个 composable统一管理录音实例的状态。4.4 断线后 WebSocket 不自动恢复手动指数退避重连现象网络切换比如 Wi-Fi 掉线重连、4G 切 Wi-Fi后WebSocket 连接静默断开。页面上的文字停留在某句话的半截麦克风还在录但服务端已经没有数据进来。原因WebSocket 断开时 onclose 事件确实会触发但是否重连完全由前端控制。如果只监听 onerror 不监听 onclose或者重连逻辑里直接 new 一个 WebSocket 而没有检查连接状态就会导致连接空窗。另一种情况是断线后立即重连此时服务端可能还没清理掉旧连接出现鉴权冲突。解决实现指数退避重连第一次延迟 500ms第二次 1s第三次 2s最高到 10s。每次重连前先重置鉴权 URL 的时间戳因为 date 变了之前的签名过期然后重新走一遍握手流程。重连后要把缓冲区里没发完的 PCM 帧清空因为断线期间录制的内容已经失去时间连续性继续发送会得到前后文不连贯的文本。代码结构大致是let reconnectAttempts 0 function connectWithRetry() { ws createWs(getNewAuthUrl()) // 新的鉴权URLdate必须重新生成 ws.onclose () { const delay Math.min(10000, 500 * Math.pow(2, reconnectAttempts)) setTimeout(connectWithRetry, delay) reconnectAttempts } ws.onopen () { reconnectAttempts 0 startPushingAudio() } }这里有一个容易被无视的点onclose里会执行 startPushingAudio但旧定时器可能还没清掉。所以每次重连前必须先 clearInterval否则出现两个定时器同时推帧WebSocket 顺序错乱服务端会直接报错。我把这套逻辑放在一个createRecognitionController()函数里封装避免在组件里散落一堆全局变量。5. 把识别模块放进真实业务状态管理、鉴权缓存与性能边界5.1 鉴权参数别在前端算把签名挪到后端前面给的计算签名的函数可以直接在前端跑但这只是“能跑”。如果业务里把 APIKey 和 SecretKey 写死在前端静态资源里等于把密钥明文暴露到公网。密钥会随打包产物一起被任何人看到这不是安全隐患问题而是想都不用想就会发生的事。所以真实项目里我会在前端只保留 APIKey 对应的 appId把 SecretKey 放到后端。前端的 wss URL 是通过后端接口拿的。页面点击开始录音时先请求一个后端地址后端按同样的 HMAC 算法生成签名 URL 并返回给前端前端再拿这个 URL 去创建 WebSocket。后端实现也不复杂Express 框架下核心代码大概是这样const crypto require(crypto) const base64url (str) Buffer.from(str).toString(base64) function getIatAuthUrl(req, res) { const date new Date().toUTCString() const origin host: iat-api.xfyun.cn\ndate: ${date}\nGET /v2/iat HTTP/1.1 const signature crypto .createHmac(sha256, process.env.IAT_SECRET) .update(origin) .digest(base64) const auth api_key${process.env.IAT_KEY}, algorithmhmac-sha256, headershost date request-line, signature${signature} const url wss://iat-api.xfyun.cn/v2/iat?authorization${base64url(auth)}date${encodeURIComponent(date)}hostiat-api.xfyun.cn res.json({ url }) }后端生成的 URL 有一个隐性的有效期窗口date 越旧越容易被服务端拒绝。所以这个接口不能做长时间缓存最多缓存几十秒否则客户端拿到 URL 后用户迟迟不点麦克风连接还是会被拒。我把封装这个 URL 的接口设计成每次录音前实时拉取不做全局缓存损耗只是一次很轻的 HTTP GET换来的是连接成功率。5.2 识别中按钮的锁定与结果缓存处理用户连读的场景实时语音转写的交互里有一个很容易翻车的场景用户点击一次“开始说话”说完一句后又立刻点“结束”然后马上又点“开始”。如果组件里没有一个统一的状态机就会同时出现两个 WebSocket 连接、两个麦克风流页面直接乱掉。我一般会定义一个简单的状态机state 枚举为 idle / preparing / recording / stopping。只有 idle 状态允许启动录音只有 recording 状态允许停止其他按钮点击全部忽略。stopping 状态要等 WebSocket 收到最后的结果或关闭事件后才回到 idle。这样即使用户狂点按钮录音实例也只有一个。状态切换的代码加一段 dev 模式下的日志录音开始时间、结束时间、连接耗时、发送帧数都打出来等排查问题时能直接定位是哪个环节卡住。结果缓存方面如果页面允许用户编辑识别文本我会把已经写入 finalSentences 的内容用 localStorage 做持久化。刷新后回显已定稿的文字避免用户在长语音输入过程中意外刷新导致全部白干。这个技巧对语音输入长篇内容的场景很有价值比如口述笔记但对短句搜索类场景则没必要。5.3 并发与时长边界一次连接能跑多久、多开会怎样实时语音转写接口一般有并发限制。一个 appId 同时打开的 WebSocket 连接数通常限制在个位数超过会被拒绝或直接断连。影响是如果业务里多个页面同时开启识别比如一个页面里两个组件都调用了麦克风后发起的连接会被服务端丢弃。因此模块内部要做“全局唯一连接”的约束确保整个应用同一时间只有一个活跃的 WebSocket。识别时长也有边界连续说话超过一定时间不同规格限制不同服务端会强制断开。这个边界不是按累计时长计而是按单次连接从握手到关闭的时长计。实际项目里如果用户可能需要持续说话 5 到 10 分钟比如会议转写不能靠一次连接硬扛要设计“分段续传”机制到达限制前主动关闭当前连接立刻用新的鉴权 URL 重连同时把当前 finalSentences 保留在页面上下一段接着往上累加。分段续传还有一个好处它天然避开了长连接导致的内存泄漏问题。长时间录音时如果脚本节点的缓冲区没有被及时消费内存占用会一路走高。分段重连后旧的采集队列被清空内存回落到初始状态。我做的录音控制里会用定时器统计队列长度超过阈值就强制分段这比依赖服务端断连被动的多。6. 进阶把转写结果变成可编辑文本——断句合并与标点修复6.1 为什么要处理断句服务端返回的是“片段”不是“文章”实时转写服务返回的是以静音或句意为边界的识别片段每句话独立成段中间没有逗号和句号。用户在页面上看到的效果是“一坨”文字每个词之间都断开阅读体验不如离线转写自然。这不是接口能力不行而是实时场景本身不会等你说完一整段话才返回它必须边听边出字。这意味着前端要把这些片段做二次拼接并尽可能补上标点。6.2 一个可复用的结果合并函数我的习惯是维护一个定时任务如果 1.5 秒内没有新的最终结果进来就把当前 partialText 强制视为一个段落边界往 finalSentences 里推进。这个“静音超时断句”策略比单纯依赖服务端 type 字段更稳因为用户正常说话时偶尔会有较长停顿停顿后服务端不会立即返回最终结果但语义上应该分段。这里给出一个可复用的合并函数function mergeRecognitionResult(state, result) { if (result.type 1) { // 最终结果合并到当前段落然后清空 state.currentParagraph result.text } else if (result.type 0) { // 临时结果更新当前显示但不修改段落内容 state.previewText result.text } // 超过静音阈值段落推进 if (Date.now() - state.lastResultAt 1500) { state.paragraphs.push(state.currentParagraph) state.currentParagraph } state.lastResultAt Date.now() }这个函数里previewText是页面实时显示的内容currentParagraph是正在累积的段落paragraphs是已完成段落的数组。标点修复不是必需动作但加上会让结果更可用。简单做法是做标点映射语气词后补逗号末尾无标点时先补句号。不要试图做复杂 NLP一个正则表足够支撑多数场景。6.3 别贪多识别修正与键盘编辑的协作进阶一点可以把“最终结果修正临时结果”的时机留一个手动入口让用户确认后落库。比如临时结果上屏后用灰色显示用户敲回车才确认这一段。这也算一种协作模式。做语音转写模块最重要的是控制复杂度实时流、采样率、连接管理三个核心点稳住整体就能交付。那次我接完这个模块后每次写语音类功能都会先列一张检查清单鉴权 URL 是否新鲜、重采样是否生效、断线重连是否带退避、卸载清理是否完整每一步都强制过一遍才敢提测。希望这套拆解能帮你在自己项目里少走几步弯路。本文还有配套的精品资源点击获取