简介使用Vue.js接入科大讯飞WebAPI实现实时语音转文字的完整前端资源适合具备Vue基础、希望快速落地语音识别功能的前端开发者。资源包共8个文件包含7个JavaScript脚本与1个HTML页面整体仅16KB涵盖SDK封装、音频采集、Worker后台处理、加密鉴权等核心模块代码结构紧凑便于直接套用。已有4420人学习下载项目展示了从MediaDevices获取麦克风音频、流式发送至讯飞接口、再到动态更新识别结果的完整链路并兼顾了错误处理与浏览器兼容性。值得参考的是Worker脚本与Worklet设计能帮助开发者理解如何避免主线程阻塞可将其作为基架快速构建会议转写、字幕生成等实时语音交互应用。 上个月接了一个挺有意思的内部工具需求行政部每天要整理会议录音希望把三四个小时的音频直接变成文字稿归档。一开始我图省事想的是录完再丢给转写服务结果需求方当场把我否了——会议还在开纪要就要出录完再转黄花菜都凉了。最后落到方案上的就是科大讯飞的实时语音转文字能力前端用 Vue3 搭了一个实时转写面板麦克风语音流边采边转识别文字一句句上屏。从调研到跑通可用版本大概花了两周中间踩了不少音频链路和 WebSocket 的坑。这篇文章就是把整个接入过程拆开讲清楚适合准备在 Vue 项目里接实时语音转文字的前端同学默认你已经会 vuejs 基础语法知道组件、ref、生命周期这些概念。1. 实时转写这个需求为什么会落到Vue上1.1 真实业务场景与实时的必要性会议纪要、客服质检、访谈记录、医疗病历录入……需要把语音变成文字的场景越来越常见。录音文件转写虽然成熟但有一个绕不开的问题延迟。一场会开完转录任务排队、转码、跑引擎快则几分钟慢则半小时等拿到文字稿会议结论早就被下一个会议淹没了。而实时转写把说话到文字的延迟压缩到两秒以内会议刚结束纪要基本已经成型。当你要交付这种交互体验时最直接的载体就是 Web 应用Vue 又是国内团队使用率最高的框架之一所以科大讯飞实时语音转文字 vuejs 版本这个组合几乎成了标配。我在这套方案里选的是讯飞的实时语音听写接口WebSocket 直连语音流边采边发边收结果不需要中间再架一层音频转发服务链路短了好排查。1.2 为什么不选其它方案另外也对比过其他几家方案。有的提供 REST 接口但不支持流式需要攒够一整段音频再发体验上就变成了按住说话、松开出字不适合会议这种长音频场景。讯飞实时语音听写是为流式设计的返回结果里明确区分中间结果和最终结果前端就可以做到边说边上屏说完自动修正。加上讯飞语音引擎更新到 9.0 之后长句标点、口语词修正、数字英文混读的准确率都在提升实时场景的可用性比前几年好太多。对于不想自己训练模型、不想维护 GPU 服务的团队来说业务方只需要管好采集、编码、上送、渲染这四件事其它都交给服务端。1.3 硬件终端带来的一个思路不少人对讯飞的印象还停留在手机 App 或者网页 Demo其实讯飞自己也有硬件终端比如 T30 Lite 这类移动录音设备。拆过机的人都知道里面最关键的配件是麦克风阵列和音频编解码芯片它们决定了拾音质量和信噪比。Web 端没有这种硬件红利浏览器拿到的是普通麦克风的一路裸信号所以要达到能用的识别率就得更认真地处理前端音频链路——这个话题后面专门展开。2. 先把讯飞WebSocket鉴权协议搞清楚2.1 接口形态不是拿密钥就能连讯飞的实时语音转文字Web 端对口的接口叫实时语音听写。它有两种接法一种是 HTTP 上传整段音频等结果适合录音文件转写另一种是 WebSocket 流式上送也就是我们要用的实时方案接口地址是wss://iat-api.xfyun.cn/v2/iat。和很多云服务不一样讯飞这个接口不是拿 APPID、APIKey、APISecret 三个参数直接连的。它要求你生成一个带签名的鉴权 URL本质是把请求头信息用 HMAC-SHA256 签名后放到 URL 的authorization参数里。很多新手第一次连的时候会拿一个普通 WebSocket 地址直接new WebSocket(...)结果握手一直失败就是这个签名没做对。2.2 签名与鉴权URL生成逻辑签名逻辑拆开看并不复杂取当前 UTC 时间格式化成 RFC1123 格式的date字符串。拼出一段待签名文本内容是host、date、request-line三行行间用换行符分隔。用 APISecret 作为 HMAC-SHA256 的密钥对这段文本做签名结果做 Base64 编码。把api_key、algorithm、headers、signature拼成一个新的 authorization 原串再做一次 Base64 编码。最终 URL 包含authorization、date、host三个查询参数。对应的 Node.js 服务端代码大致是这样const crypto require(crypto); function buildIatAuthUrl(appId, apiKey, apiSecret) { const host iat-api.xfyun.cn; const path /v2/iat; const date new Date().toUTCString(); const signatureOrigin host: ${host}\ndate: ${date}\nrequest-line: GET ${path} HTTP/1.1; const signature crypto .createHmac(sha256, apiSecret) .update(signatureOrigin) .digest(base64); const authorizationOrigin api_key${apiKey}, algorithmhmac-sha256, headershost date request-line, signature${signature}; const authorization Buffer.from(authorizationOrigin).toString(base64); return ( wss://${host}${path}?authorization${encodeURIComponent(authorization)} date${encodeURIComponent(date)}host${host} ); }注意date和authorization放进 URL 时必须 encodeURIComponent尤其是authorization里的引号和逗号不编码的话服务器解析会出错。这个 URL 的有效期是 5 分钟所以只适合用来建立即时连接不适合提前生成一大批存着复用。2.3 为什么密钥不能放Vue前端网上很多教程为了省事把签名逻辑直接写在 Vue 里面APISecret 硬编码在源码里。这是我在工程上最反对的做法。Vue 打包后的 JS 虽然是压缩过的但只要有人有心放到 Chrome DevTools 里格式化一下字符串常量还是能翻出来。APISecret 一旦泄露别人就可以用你的账号去调接口跑掉的是你的配额和费用。我的做法是在后端加一个轻量代理接口比如GET /api/iat-url负责读取环境变量里的密钥、动态生成签名 URL 并返回。Vue 端只负责请求这个接口、拿 URL、建 WebSocketconst resp await fetch(/api/iat-url); const { url } await resp.json(); const socket new WebSocket(url);密钥只存在于服务端环境变量中前端代码里不出现任何敏感信息。这个改造成本很低但能避免一整个类别的安全事故。3. Vue端音频采集与PCM帧处理3.1 浏览器采集麦克风的最小闭环Vue 组件里拿到麦克风数据标准链路是navigator.mediaDevices.getUserMedia({ audio: true })获取 MediaStream。new AudioContext()创建音频上下文。audioContext.createMediaStreamSource(stream)把麦克风流接到音频图上。创建AudioWorkletNode在音频线程里处理 PCM 数据。AudioWorklet 的process回调会在音频渲染线程里高频触发输入是 Float32 类型的样本数组。我们就在这里完成格式转换再把数据通过port.postMessage发给主线程。示例代码class PCMProcessor extends AudioWorkletProcessor { process(inputs) { const input inputs[0]; if (!input || !input[0]) return true; const channelData input[0]; // 在这里做降采样、格式转换 this.port.postMessage(channelData); return true; } }inputs[0]是第一个输入轨道的声道数组input[0]通常对应左声道。多声道麦克风在浏览器里一般会被混成单声道或者双声道我们取第一路即可因为讯飞接口接收的是单声道 PCM。3.2 为什么一定要重采样到16k讯飞实时语音转写接收的音频格式是audio/L16;rate16000也就是 16kHz 采样率、16bit 位深的裸 PCM 流也可以是 8000Hz。但浏览器麦克风默认采样率通常是 48000Hz有些设备是 44100Hz。直接把 48k 采样率的数据标成 16k 发过去会怎样声音的频谱会被压缩四倍识别结果基本是乱码。反过来如果你告诉服务端格式是 48000讯飞接口又不支持会直接握手失败。所以必须在采集端把采样率降下去。最简单的降采样实现是均值降采样每 N 个样本取平均值输出一个样本。对语音识别来说这种方式的精度足够而且不会像抽稀那样产生明显混叠function downsample(buffer, inputRate, outputRate) { if (inputRate outputRate) return buffer; const ratio inputRate / outputRate; const newLength Math.floor(buffer.length / ratio); const result new Float32Array(newLength); let offset 0; for (let i 0; i newLength; i) { const nextOffset Math.floor((i 1) * ratio); let sum 0; let count 0; while (offset nextOffset offset buffer.length) { sum buffer[offset]; count; } result[i] count 0 ? sum / count : 0; } return result; }注意这里把多出来的样本丢弃会丢弃掉非常短的人声尾部但 40ms 级别的丢帧对识别影响很小。追求效果的话可以加一个低通滤波器再做抽稀但对绝大多数通话场景均值降采样已经够用。3.3 AudioWorklet的加载坑AudioWorkletNode 的处理器不是普通函数它必须通过audioWorklet.addModule(url)加载。这个 URL 有讲究Vue 项目如果打包后把处理器文件放在 static 或 public 下路径要写成绝对路径比如/pcm-processor.js。用相对路径很容易 404因为路由切换后相对路径会变。还有一种是 Blob URL 方案把处理器源码字符串化创建 Blob 后用URL.createObjectURL生成地址再传给 addModule。这个方案的好处是不用管构建配置我在 Demo 项目里经常用它const processorCode class PCMProcessor extends AudioWorkletProcessor { process(inputs) { ... } } registerProcessor(pcm-processor, PCMProcessor); ; const blob new Blob([processorCode], { type: application/javascript }); const url URL.createObjectURL(blob); await audioContext.audioWorklet.addModule(url);4. WebSocket传输与结果实时渲染4.1 第一帧是参数帧不是音频帧WebSocket 连接建立后不能直接丢音频二进制。第一帧必须是一个文本帧内容是 JSON 的 Base64 编码里面包含三段信息common应用信息、business业务参数、data音频格式描述。一个最简参数如下{ common: { app_id: 你的APPID }, business: { language: zh_cn, domain: iat, accent: mandarin, vad_eos: 2000, dwa: wpgs, ptt: 1 }, data: { status: 0, format: audio/L16;rate16000, encoding: raw } }其中dwa: wpgs是动态修正参数开启后服务端会持续返回修正后的文本这是实时转写体验的关键。ptt: 1开启标点vad_eos: 2000表示语音断句的静音阈值是 2000ms。发送时用socket.send(btoa(JSON.stringify(param)))转成文本帧。我这里为了演示简洁用了 btoa生产环境建议用更健壮的 Base64 编码函数避免中文和 Unicode 转义导致长度不一致。4.2 音频帧发送节奏参数帧之后所有音频数据都通过 WebSocket 的二进制帧发送。每帧发送间隔建议控制在 40ms 到 60ms每帧对应的 PCM 数据量是 采样率 16000 × 帧长。比如 40ms 一帧每帧就是 640 个样本转成 16bit PCM 就是 1280 字节。AudioWorklet 的 process 回调触发频率跟设备采样率有关不是稳定的 40ms所以不能在里面直接调 socket.send。我的做法是AudioWorklet 线程只负责生成降采样后的 Float32 数组通过 postMessage 推到主线程的队列主线程再用 setInterval 按固定节奏从队列取数据发送。const interval setInterval(() { if (queue.length 0) return; const chunk queue.shift(); const pcmBuffer floatTo16BitPCM(chunk); socket.send(pcmBuffer); }, 40);floatTo16BitPCM把 Float32 转成 Int16 字节序function floatTo16BitPCM(float32Array) { const buffer new ArrayBuffer(float32Array.length * 2); const view new DataView(buffer); for (let i 0; i float32Array.length; i) { const s Math.max(-1, Math.min(1, float32Array[i])); view.setInt16(i * 2, s 0 ? s * 0x8000 : s * 0x7fff, true); } return buffer; }这里有一个容易忽略的点WebSocket 发送是异步的如果 setInterval 的回调里积压了大量未发送数据说明主线程跟不上了此时应该丢帧而不是排队硬发。我在项目里给队列设了最大长度比如 50 帧超过就直接丢弃最老的帧保证音频流的实时性。4.3 返回结果解析与Vue响应式渲染讯飞服务端返回的是 JSON 文本帧结构大致是{ code: 0, data: { result: { ls: false, ws: [ { cw: [ { w: 今天 } ] }, { cw: [ { w: 下午 } ] } ] } } }ws是词数组每个词块里的cw里可能有多个候选词通常取第一个。ls表示是否为最终结果false是中间结果true是稳定结果。开启了dwa: wpgs后中间结果可能会被后续的稳定结果修正所以 UI 上要做两层文本区一层是稳定的历史文本一层是正在修正中的临时文本。Vue3 里我用两个 ref 分别保存const stableText ref(); const tempText ref(); socket.onmessage (event) { const msg JSON.parse(event.data); if (msg.code ! 0) { console.error(识别错误, msg.code, msg.message); return; } const result msg.data.result; let sentence ; result.ws.forEach(group { group.cw.forEach(cw { sentence cw.w; }); }); if (result.ls) { stableText.value sentence; tempText.value ; } else { tempText.value sentence; } };页面上把stableText当作已经确定的正文tempText用半透明样式显示在末尾用户看的时候就有一种实时在写的感觉。会话结束时记得发送结束帧JSON 内容为{ data: { status: 2 } }然后等服务端返回最终结果再关闭连接socket.send(JSON.stringify({ data: { status: 2 } })); // 收到最后一个结果后再 socket.close()5. 实际联调中的六个坑和排查路径5.1 音频链路最折磨人的三个坑坑一AudioWorklet 模块 404 或跨域加载失败。现象是控制台报Failed to load module scriptprocess 回调根本不触发。排查路径先确认请求的 JS 文件地址在浏览器 Network 面板里是不是 200再确认文件里有没有registerProcessor调用最后确认打包器没有把该文件当成普通模块做 hash 改名。我的解决办法是优先用 public 目录放处理器文件或者干脆用 Blob URL彻底绕开构建器。坑二48k 采样率当 16k 发。现象最诡异接口不报错返回码正常但识别结果是一连串喂喂喂或者完全对不上的字。排查路径在 AudioWorklet 的 process 回调里打印sampleRate和channelData.length核对每帧样本数。如果是 48k 采样40ms 一帧的样本数是 1920而我期望的是 640。确认后补上降采样即可。坑三Float32 当 PCM16 直接发。现象是识别出来的音频像机器人乱码或者服务端直接报格式错误。因为 WebSocket 二进制帧需要的是 16bit PCM每个样本占两个字节而 Float32 每个样本占四个字节且取值范围是 -1 到 1。排查路径把发送的 ArrayBuffer 字节数按公式算一遍640 个样本的 Float32 是 2560 字节转成 PCM16 后应该是 1280 字节。如果字节数对不上就是没转。5.2 连接与工程上的三个坑坑四发送节奏不稳定导致长会话断连。现象是短对话没问题跑一两分钟后服务端开始断连或者返回异常错误码。原因是 setInterval 在主线程卡顿时会延迟音频帧要么堆积要么断裂。排查路径看浏览器 Performance 面板确认是不是有长任务阻塞了主线程看队列长度是否持续增长。解决思路是严格控制每帧样本数在 AudioWorklet 里按固定帧大小切分主线程只做转发。坑五停止时没发结束帧。现象是最后几个字丢失或者下一次初始化连接时服务端返回上一次会话未结束。这个坑特别隐蔽因为在 WebSocket close 时服务端确实会以为会话异常中断。排查路径检查停止按钮的处理函数是否只调了socket.close()而没有先发{ data: { status: 2 } }这个结束帧。我的经验是停止时先发结束帧然后开一个 3 秒的定时器收到最终结果就关闭超时没收到也强制关闭防止连接泄漏。坑六APISecret 出现在打包产物里。这个坑我在帮朋友排查问题的时候真实遇到过生产环境的一个 chunk.js 里可以直接搜到 APISecret 明文。排查路径很简单用编辑器搜打包产物目录。解决方式就是前面说的签名逻辑必须放后端前端只通过接口拿鉴权 URL。如果已经泄露还要立即去控制台轮换密钥。6. 从能用走向好用6.1 动态修正和标点要分开处理建议把中间结果和最终结果严格分开渲染不然用户会看到文字跳来跳去。中间结果可以用灰色字显示在稳定文本尾部动态修正的差异就不会打扰阅读。标点这块讯飞引擎 9.0 的断句和标点重写能力比旧版好很多但转写出来的标点有时还是需要人工复核特别是人名、地名后面别把逗号位置当成可检索信息。6.2 静音检测和自动停句服务端有vad_eos参数控制断句静音阈值但前端也可以做一层 VAD语音活动检测在 AudioWorklet 里算每帧能量低于阈值就标记为静音。连续静音超过设定时间可以自动截断当前句把临时文本固化到稳定文本。这样做的好处是长会场景下不会出现一句话拖几分钟都不上屏的情况。6.3 硬件联动和拾音建议再回到 T30 Lite 这类讯飞硬件终端它的价值在于麦克风阵列和前端降噪这是普通笔记本麦克风比不了的。Web 端能做的补偿有限但可以通过getUserMedia里的echoCancellation、noiseSuppression开关改善一部分环境噪音。多人会议场景我的建议很直接给发言人配一个独立的 USB 麦克风比任何软件优化都有效。这套东西做完我自己最大的感受是实时转写的瓶颈从来不在接口调用而在音频链路。浏览器把音频处理切得很碎80% 的问题都出在你以为你发出去的格式和服务端实际收到的格式不一致上。所以我现在做任何语音项目都会先写一个 PCM dump 工具把每一帧的采样率、字节数、时间戳打出来验证先把数据链路钉死再去调 UI 和交互。如果你也用 Vue3记得把音频采集和 WebSocket 逻辑抽成独立的 Composable别全堆在组件里否则后面加需求的时候改起来真的很痛苦。本文还有配套的精品资源点击获取