ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Vue.js+WebSocket实现科大讯飞流式语音听写:从麦克风到实时字幕的完整指南

Vue.js+WebSocket实现科大讯飞流式语音听写:从麦克风到实时字幕的完整指南

简介:一套基于科大讯飞语音识别服务的流式语音听写实现方案,面向需要在前端接入实时语音转文字功能的Vue.js开发者,也兼容传统HTML页面场景。方案通过WebSocket建立与讯飞服务的全双工通信,利用Web Audio API采集麦克风音频,并给出Vue组件化与原生DOM操作两种实现版本,便于按项目架构灵活选用。压缩包共含37个文件,以20个JavaScript脚本和6个Vue组件为核心,辅以TypeScript类型定义、HTML页面、JSON配置及Markdown文档,分别覆盖核心逻辑、界面封装、类型约束和使用说明;整体仅112KB,轻量易部署,适合直接嵌入现有项目。目前已有2203人学习下载。资源按示例与源码模块化组织,既可直接基于示例改造接入,也可对比Vue响应式与原生DOM两种写法,从而深入理解实时通信、浏览器录音和前端音频处理的关键细节。

1. 科大讯飞 Vue.js 语音听写流式实现:一条音频从麦克风到字幕的实时链路

做会议字幕或客服质检工具时,第一次接触科大讯飞 Vue.js 语音听写流式实现,很容易把它理解成一套拿来即用的语音转文字组件。拆开看,它其实是一条贯穿三层的实时链路:浏览器麦克风采集、WebSocket 长连接上的音频帧、以及按句返回的中间识别结果。目标只有一个——用户一边说话,文本一边冒出来,而不是等整段录音结束再回传。之所以强调 Vue,是因为前端这边真正花时间的不是界面,而是连接建立时机、结束帧发送时机和页面销毁时的资源释放。适合想在 1 到 2 周内把实时听写能力接进产品,而不是从零研究语音算法或训练声学模型的团队。

整条链路里最容易被卡住的三个点,分别是鉴权 URL 的签名、PCM 音频格式的匹配、以及服务端中间结果和最终结果的状态区分。Vue 本身只负责把这些点串成可控的用户体验。下面按我实际接项目的顺序来讲:先打通协议,再处理录音,最后聊状态机和那些不踩一遍很难发现的坑。

2. 流式接口的钥匙:WebSocket 鉴权 URL 和帧协议

2.1 为什么实时听写必须用 WebSocket,而不是把整段音频丢给 HTTP

离线文件转写走的是“上传完整文件,等待异步回调”的 REST 流程,适合录音文件事后转文字。实时听写的体验要求是边说边出,服务端必须在音频到达过程中持续回传增量结果,客户端和服务端之间需要一条长时间保持的双向通道。讯飞语音听写流式版对外开放的端点是一个 WebSocket 地址,路径通常落在 /v2/iat 这一类接口上,整条链路就是建立在 wss 协议之上的。

这里最常见的误解是以为直接new WebSocket('wss://iat-api.xfyun.cn/v2/iat')就能连上。实际服务端会先校验地址里的签名参数,校验不过就直接断开或拒绝握手。浏览器端的 WebSocket API 不允许自定义 Authorization 请求头,所以这类语音服务普遍把签名放进 URL 的 query 参数里。理解了这一点,后续的调错思路就不会跑偏:凡是握手阶段失败,优先怀疑签名,而不是怀疑音频格式。

签名方案本身属于 HMAC-SHA256 加 base64 的组合,很多做过云服务接口对接的开发者会觉得熟悉。它把 host、date、request-line 拼成一个待签名字符串,用 APISecret 作为密钥计算签名,再把签名拼进 authorization 字符串,最后整体 base64 编码后放入 URL。只要把这条生成链路独立封装好,调试时能省掉大量反复试错的时间。

2.2 鉴权 URL 的生成:host、date、request-line 三件套

我习惯用一个独立模块来生成鉴权 URL,不把它散落在 Vue 组件里。这样不管是调试还是将来换框架,都能复用同一套逻辑。下面是一个用 CryptoJS 实现的生成函数,适合在浏览器端或 Node 环境运行:

import CryptoJS from 'crypto-js'; function buildAuthUrl(apiKey, apiSecret) { const host = 'iat-api.xfyun.cn'; const path = '/v2/iat'; const date = new Date().toUTCString(); const requestLine = `GET ${path} HTTP/1.1`; // 待签名字符串,三行之间是换行符,不能多也不能少 const signatureOrigin = `host: ${host}\ndate: ${date}\nrequest-line: ${requestLine}`; // 用 APISecret 作为 HMAC 密钥,结果转 base64 const signature = CryptoJS.HmacSHA256(signatureOrigin, apiSecret).toString(CryptoJS.enc.Base64); // 按约定拼出 authorization 字符串 const authorization = `api_key="${apiKey}", algorithm="hmac-sha256", headers="host date request-line", signature="${signature}"`; // 对 authorization 整体再做一次 base64 const authorizationBase64 = CryptoJS.enc.Base64.stringify( CryptoJS.enc.Utf8.parse(authorization) ); const params = new URLSearchParams({ authorization: authorizationBase64, date: date, host: host }); return `wss://${host}${path}?${params.toString()}`; }

这段代码里值得留意的有三处。第一,signatureOrigin中的换行必须是字符串里的\n,host 这一行不能带https://前缀,更不能顺手加端口,除非对应版本文档明确要求带端口。第二,date用的是toUTCString(),也就是 RFC 1123 格式,签名里的 date 和 URL 参数里的 date 必须是同一个值,如果生成 URL 之后页面停留太久,这个签名会过期。第三,URLSearchParams 会自动处理 base64 中的+、/、=字符,浏览器地址栏里显示成%2B是正常现象,服务端会先解码再验签,不需要手动反转义。

2.3 音频帧与 text 消息:二进制的节奏控制

鉴权 URL 拿到之后,连接建立只是第一步。协议层的数据交互分成三个阶段:先发一个 JSON 文本帧,携带应用参数;然后持续发送二进制音频帧;最后再发一个 JSON 文本帧通知服务端“音频结束”。三个阶段的顺序一旦颠倒,服务端会直接判定协议错误。

第一阶段的第一帧文本消息大致是这样:

ws.onopen = () => { const firstFrame = { common: { app_id: APP_ID }, business: { language: 'zh_cn', domain: 'iat', accent: 'mandarin', vad_eos: 10000, // 静默多久认为一句话结束,单位毫秒 dwa: 'wpgs' // 开启后结果里会带更细的边界字段,按需使用 }, data: { status: 0, // 0 表示还有后续数据 format: 'audio/L16;rate=16000', encoding: 'raw', audio: '' // 文本帧里 audio 为空 } }; ws.send(JSON.stringify(firstFrame)); };

这里的 business 参数决定了识别场景。vad_eos控制静默断句的时长,会议场景需要调大,短句对话场景可以调小。format里的audio/L16;rate=16000表示 16kHz 采样率、16bit 位深的裸 PCM 数据,encoding: raw表示不压缩。如果麦克风采集到的原始数据不是这个格式,必须在发送前完成转换,后面第三章会专门讲。

第二阶段是二进制帧的持续发送。按照 16kHz、16bit、单声道计算,40ms 音频恰好是 1280 字节,这也是大多数实现里单帧的推荐长度。客户端按这个长度把音频切块,依次通过ws.send(buffer)发送,期间不要再插入其他 JSON 文本帧。第三阶段是发送结束信号,逻辑上同样是一个 JSON 文本帧,但data.status变成 2,表示“我这边没有更多音频了”:

ws.send(JSON.stringify({ data: { status: 2 // 2 表示客户端发送结束 } }));

注意这个status是客户端发送方向的状态,和服务端返回结果里的status含义完全不同。服务端返回的data.status也有 0 和 1/2,但表达的是“这段内容是中间结果还是最终结果”。这两个词汇很容易在代码里混用,我一般会在变量名上强制区分,比如sendStatus和respStatus。

3. 在 Vue 里接麦克风:AudioWorklet 降采样与 PCM 帧的发送节奏

3.1 选录音链路:MediaRecorder 的免配置陷阱

很多开发者第一次做浏览器录音,第一反应是用MediaRecorder,因为它 API 简单,能直接拿到录音文件。但这个选择在流式听写场景里会带来一个绕不过去的坑:MediaRecorder默认输出的是 WebM/Opus 格式,而讯飞流式听写接口要求的是裸 PCM 或特定编码格式。如果把 WebM 的二进制数据直接放到ws.send里,服务端会返回音频格式类错误,而且这个错误往往不是立刻返回,而是等发送到一定量才报出来,排查成本很高。

还有一条路是把MediaRecorder得到的 Opus 数据用decodeAudioData解码后再发给服务端。问题在于decodeAudioData通常需要等拿到完整的一段音频才能解码,这实际上把流式变成了准实时,中间结果的延迟会明显变大。所以我的选择是用 Web Audio API 取原始 PCM:先通过getUserMedia拿麦克风流,再创建AudioContext,用AudioWorklet做降采样和格式转换。AudioWorklet 相比旧的ScriptProcessorNode延迟更低,运行在独立音频线程里,不会因为主线程渲染卡顿而丢数据。

选择这条路的原因还有一个工程上的考量:AudioWorklet 的处理器可以在音频线程里维护一个环形缓冲,精确地按 1280 字节切帧。这样主线程只需要负责把切好的帧转发给 WebSocket,职责单一,出了问题也容易定位是采集环节还是传输环节。

3.2 AudioWorklet 里的降采样和 PCM 拼帧

浏览器麦克风采集的默认采样率通常是 48000,而接口要的是 16000。降采样我一般用简单的线性插值实现,虽然不如高阶滤波器精度高,但对于语音识别场景足够了,CPU 占用也更低。下面是 Processor 的核心实现:

class DownsamplerProcessor extends AudioWorkletProcessor { constructor() { super(); this.buffer = new Float32Array(0); this.SRC_RATE = sampleRate; // AudioWorklet 运行时的采样率 this.TARGET_RATE = 16000; this.chunkSamples = 640; // 1280 字节 / 2 字节每样本 } process(inputs) { const input = inputs[0] && inputs[0][0]; if (!input) return true; const ratio = this.SRC_RATE / this.TARGET_RATE; const outLength = Math.floor(input.length / ratio); const downsampled = new Float32Array(outLength); // 线性插值降采样,ratio 一般是 3 for (let i = 0; i < outLength; i++) { const srcIndex = i * ratio; const p = Math.floor(srcIndex); const q = Math.min(p + 1, input.length - 1); const frac = srcIndex - p; downsampled[i] = input[p] * (1 - frac) + input[q] * frac; } // 拼接上一次剩余的数据 const merged = new Float32Array(this.buffer.length + downsampled.length); merged.set(this.buffer, 0); merged.set(downsampled, this.buffer.length); this.buffer = merged; // 每凑满 640 个样本,转成 Int16 后发给主线程 while (this.buffer.length >= this.chunkSamples) { const chunk = this.buffer.slice(0, this.chunkSamples); const pcm = new Int16Array(this.chunkSamples); for (let i = 0; i < this.chunkSamples; i++) { const v = chunk[i] * 32767; pcm[i] = Math.max(-32768, Math.min(32767, v)); } this.port.postMessage(pcm.buffer, [pcm.buffer]); this.buffer = this.buffer.slice(this.chunkSamples); } return true; } } registerProcessor('downsampler', DownsamplerProcessor);

这个处理器里有几个细节决定了它能不能直接用于生产。降采样时不能简单跳点采样,否则高频成分会产生混叠,导致识别率下降;线性插值虽然简单,但在 16kHz 场景下比跳点采样可靠得多。转 Int16 时要注意浮点 PCM 的取值范围是 -1 到 1,必须先乘以 32767 再做截断,否则会出现音量极小或削波破音的问题。postMessage的第二个参数是转移列表,把pcm.buffer转移给主线程后,音频线程里这块内存就不再可写,能减少一次拷贝,但同时意味着不能在同一段代码里复用这个 buffer。

主线程拿到这个ArrayBuffer后直接塞进 WebSocket 就行。发送节奏上有一点需要克制:不要因为 AudioWorklet 产生帧的速度稳定,就用定时器机械地每隔 40ms 发一帧。更稳妥的做法是在主线程维护一个队列,收到 processor 的消息就入队,WebSocket 处于 open 状态时就发送。如果ws.bufferedAmount持续增大,说明网络或服务端处理不过来,此时应该暂停发送并考虑降级提示,而不是让帧继续堆积。

3.3 Vue 组件生命周期与麦克风资源的绑定

Vue 组件里需要处理的不只是音频转发,还有麦克风权限、组件卸载和路由切换。实际操作中,我会把一个典型的听写组件拆成 start、stop、dispose 三个方法,其中 dispose 必须在 onUnmounted 里被无条件调用:

<script setup lang="ts"> import { ref, onMounted, onUnmounted } from 'vue'; const text = ref(''); let ws: WebSocket | null = null; let audioContext: AudioContext | null = null; let stream: MediaStream | null = null; let workerNode: AudioWorkletNode | null = null; async function start() { // 先申请麦克风权限 stream = await navigator.mediaDevices.getUserMedia({ audio: true }); // 创建 AudioContext,并加载 AudioWorklet 模块 audioContext = new AudioContext({ sampleRate: 48000 }); await audioContext.audioWorklet.addModule('/downsampler.js'); workerNode = new AudioWorkletNode(audioContext, 'downsampler'); workerNode.port.onmessage = (event) => { if (ws && ws.readyState === WebSocket.OPEN) { ws.send(event.data); } }; const source = audioContext.createMediaStreamSource(stream); source.connect(workerNode); workerNode.connect(audioContext.destination); // 这里再建立 WebSocket 并发送第一帧 } function stop() { // 发送 status: 2 结束帧,等服务端最终结果 } function dispose() { stream?.getTracks().forEach((track) => track.stop()); audioContext?.close(); ws?.close(); workerNode?.disconnect(); } onUnmounted(() => { dispose(); }); </script>

这段组件代码里的顺序是经过几次调整后定下来的。必须先getUserMedia再创建 AudioContext,因为浏览器要求音频上下文要在用户手势之后才能启动。AudioWorkletNode连接audioContext.destination是为了让音频流真正跑起来,如果不连接输出节点,部分浏览器会把整个音频图挂起,process 不会触发。另外addModule的路径不能写错,这个模块加载是异步的,失败时要在 catch 里给用户提示,否则会出现点击录音后完全无响应的“假死”状态。

麦克风权限被拒绝是另一个需要单独处理的场景。getUserMedia抛出的错误类型里,NotAllowedError和NotFoundError要给不同的 UI 反馈,前者提示去浏览器设置里打开授权,后者提示检查设备。如果不区分,用户会在一个模糊的“录音失败”提示里反复点击,体验很差。

4. 听写状态机与结果渲染:从中间结果到稳定的实时字幕

4.1 状态机设计:从 idle 到 closed 的清晰流转

实时听写和普通请求最大的区别是它有多个阶段,而且阶段之间并不总是线性推进。网络断开、用户中途暂停、静默超时都会让状态发生跳变。如果不做状态机,代码里会出现大量互相矛盾的布尔值,比如“正在录音”和“已断开”同时为真,导致按钮状态混乱。我用的状态集合是这样一组:

type ListenState = | 'idle' // 初始状态,什么都没做 | 'connecting' // WebSocket 握手阶段 | 'ready' // 连接已建立,可以开始录音 | 'recording' // 正在录音并发送音频 | 'finalizing' // 已发送结束帧,等待最终结果 | 'closed'; // 会话结束,资源已释放

connecting和ready分开是必要的,因为 WebSocket 的onopen只是一个信号,第一帧文本发送完成后服务端可能还需要几百毫秒准备识别引擎。如果用户在这个窗口内说话,音频帧会被丢弃。我一般以服务端返回的第一个响应作为ready的进入条件,而不是前端onopen。

finalizing这个状态特别容易漏掉。发送完data.status: 2之后,服务端不会立刻关闭连接,而是要先吐完最后一句中间结果,再返回一个代表结束的响应。如果前端在发送结束帧后马上把按钮恢复成“录音”,用户就会看到字幕还在滚动但已经无法继续输入。所以 stop 操作之后必须停留在finalizing,直到收到服务端结束响应才置为closed。

状态机的实现不一定要引入重量级状态库,Vue 3 里用一个ref<ListenState>加几个计算属性就能撑住。关键是所有状态转换只能通过明确的函数进行,比如start()、stop()、handleWsClose(),不要在onmessage回调里随手改状态,否则后续排查时根本想不起状态是在哪里被改坏的。

4.2 结果解析:respStatus 为 0 与 1/2 的拼接规则

服务端推送过来的每一帧 JSON 里都带有data.status,这个respStatus决定了解析策略。为了不搞混,我会在代码注释里把客户端发送方向和接收方向的状态分开说明,下面这个函数专门负责从一帧响应里提取文本:

function parseIatResult(payload: any): string { const resultStr = payload?.data?.result; if (!resultStr) return ''; const result = JSON.parse(resultStr); // ws 是词序列,cw 是候选字数组,通常取第一个候选 return result.ws .map((wsItem: any) => wsItem.cw.map((cwItem: any) => cwItem.w).join('') ) .join(''); }

中间结果和最终结果的拼接逻辑是完全不同的。当respStatus === 0,返回的是当前识别区间内的增量文本,但它不代表最终结果,后续可能会修正。UI 上的正确姿势是创建一个“临时字幕区”,每次拿到新的中间结果就整体替换这个区域,而不是追加。等到respStatus为 1 或 2,这个句子基本定稿,再把它从临时区移入正式历史记录区。

还有一个容易被忽略的字段是data.result.sn,它代表句子编号。当一次会议里有长句、有短句,服务端可能交错返回不同句子的结果。只按到达顺序拼接会出现词序错乱。我通常用一个 Map 按sn暂存,等同一个 sn 的状态变为最终后再按序合并。data.result.ls为 true 时表示这已经是最后一句,可以触发最终的展示逻辑。

4.3 超时、重连与“正在思考”的 UI 降级

WebSocket 在移动端并不稳定。手机锁屏、切换 Wi-Fi 或只是从后台切回前台,连接都可能在毫秒级被掐断,而前端却要等onclose触发才能感知。更麻烦的是,重连不能简单地拿旧 URL 再new WebSocket一次,因为鉴权 URL 里的 date 已经过期,而且服务端那边的识别会话已经结束,重连成功也不能接着旧状态继续识别。

我一般把重连策略控制在会话层面,而不是帧层面。如果断开发生在录音刚开始、还没有任何最终结果产出时,可以自动重连并让用户重新说一遍;一旦已经有内容进入正式记录区,我不会尝试自动恢复,而是把已识别文本保存下来,提示用户手动继续下一段。自动重连的鉴权 URL 必须重新生成,不能复用之前的 URL,否则大概率会被签名校验拒掉。

UI 降级同样重要。服务端在处理一段较长的静默时,中间结果会停顿几秒,这时用户不知道是网络断了还是识别器在思考。我会在超过 2 秒没有新中间结果时显示“正在识别中”的动画,超过 5 秒则提示“检测不到声音或网络延迟”,把控制权交还给用户。这类反馈看似是小事,但在客服质检这样的真实场景里,直接决定了这套功能会不会被用户关掉。

5. 避坑篇:讯飞流式听写在真实环境里的 5 个翻车点

5.1 现象:wss 握手一直 401/1006,签名照抄还是失败

这个坑几乎每个接流式接口的开发者都会遇到一次。表面现象是WebSocket的onerror触发,浏览器控制台报 1006,或者服务端返回带错误码的关闭帧。原因通常出在三个地方:待签名字符串里的 host 写错,比如带了https://前缀或者多了一个端口号;date 使用了本地时间格式而不是toUTCString();还有的组件库会对 URL 参数中的+号自动解码,导致传给WebSocket的地址已经不是原始签名串。

解决方法是把签名逻辑做成独立函数,先用 Node 脚本在本地生成一条完整的 wss 地址,粘贴到在线 WebSocket 测试工具里验证能连上,再接进 Vue。这一步能过滤掉环境相关的问题。另外调试时不要反复刷新页面却不关闭旧连接,讯飞对单账号并发连接数有限制,旧连接还活着,新连接可能直接被拒绝,表现为间歇性连接失败。

5.2 现象:服务端返回 10907/10908,音频参数看着没问题

这类错误码属于音频数据异常。连接正常,第一帧也发了,但服务端在解音频时发现数据不合法。常见原因不是参数写错,而是前端发送的字节流和参数不匹配。比较典型的是 Float32 数据没有正确转成 Int16,直接以Float32Array的底层字节发送,服务端解析出来全是噪声或空数据。还有的麦克风设备默认采集立体声,前端没有做单声道合并,导致采样率对不上。

解决方法是先在 AudioWorklet 里统一完成单声道转换和 Int16 转换,再用wc -c之类的工具检查生成文件的字节数。例如 10 秒 16kHz 单声道 PCM 文件应该是 10 × 16000 × 2 = 320000 字节。如果字节数对不上,问题就在采集端,不要急着改服务端参数。

5.3 现象:字幕一直出中间结果,最后一句永远不落盘

中间结果持续滚动,但最后一句始终没有变成正式结果,用户点停止后字幕定格在半句上。原因几乎都是结束帧没有发对。有的实现把结束帧写成了{ data: { status: 2 } },但因为 JSON 字段名拼错或者直接发送了空对象,服务端一直等不到结束信号。另一种情况是收到结束帧后,前端立刻调用了ws.close(),服务端还没来得及返回最终结果就被掐断了。

解决方法是把结束流程固定成两步:先发送结束帧,然后等待服务端返回respStatus为 2 的最终响应,完整处理完这帧之后,再由前端关闭连接。在这之间可以加一个超时兜底,比如 3 秒内没收到最终响应就提示用户主动结束。需要反复确认的一点是,结束帧里的status是发送方向的状态值,别把服务端返回状态里的 2 当成客户端要发的状态,两边语义完全不同。

5.4 现象:切路由后麦克风还在闪,页面凉了还在录音

Vue 单页应用里切换路由后,组件实例会被卸载,但如果卸载时没有释放资源,浏览器地址栏的麦克风图标会一直亮,甚至页面已经被销毁了录音还在继续。原因是在onUnmounted里只关了 WebSocket,没有停掉MediaStreamTrack,也没有关闭AudioContext。

解决方法是把资源的释放集中到一个dispose函数里,按固定顺序执行:先停止所有stream.getTracks(),再断开AudioWorkletNode连接,最后关闭 AudioContext 和 WebSocket。这里有个细节:stream.getTracks()必须在关闭 AudioContext 之前调用,有些浏览器如果先关闭了音频上下文,track 的stop会变得不彻底,设备指示灯依然亮着。

5.5 现象:静默几秒就被切断,长停顿之后第一句不识别

会议场景里说话人经常停顿三五秒,每次都触发断句,下一句话开头半个字被吞掉。原因在于business里的vad_eos参数默认值比较小,服务端认为静默超过阈值就结束当前句,开始返回最终结果并等待下一句。如果前端没有为这种“人为断句”做好准备,下一次说话时会发现连接还在,但音频帧没有被正确接上。

解决方法是在业务参数里把vad_eos调大,会议纪要场景我一般调到 10000 到 15000 毫秒,短句对话场景用 3000 到 5000 毫秒。同时客户端的停止按钮逻辑要跟vad_eos配合:用户手动点停止时发送结束帧,静默超过vad_eos时让服务端自动断,两边不要重复触发。若发现长停顿后第一句丢字,还要检查是不是前端在暂停期间没有继续发送静音帧,导致音频流出现空档。

6. 上线前的最小链路验证:先打通接口,再优化体验

很多项目出问题不是因为代码写错,而是因为调试时把所有环节混在一起。我的习惯是先用一条最小链路验证接口,完全不碰 UI。具体做法是用 ffmpeg 准备一段固定时长的 16kHz 单声道 PCM 文件,再用 Node 脚本直连 WebSocket,把鉴权 URL 生成、音频发送、结束帧发送和结果接收跑完。这一步通了,再去 Vue 里接麦克风,排查范围会小很多。

# 从任意音频文件生成 16kHz 单声道 PCM 测试文件 ffmpeg -i input.wav -ar 16000 -ac 1 -f s16le test_16k.pcm # 查看文件字节数,5 秒应为 5 * 16000 * 2 = 160000 字节 ls -l test_16k.pcm

验证脚本里我会把每个关键节点的时间戳打出来:连接建立时间、第一帧发送时间、第一条中间结果返回时间、最终结果返回时间。首字返回时间基准上应该控制在发送后 300 到 1000 毫秒内,如果超过 2 秒,说明问题可能在网络环境或接口地区节点上。这些时间和音频文件本身无关,纯粹是链路健康度的体检。

验证通过后再回到 Vue 端做体验优化,这时才会去调 AudioWorklet 的帧大小、WebSocket 发送队列长度、以及状态机的重连阈值。帧大小我一般保持在 40ms,也就是 1280 字节,这个值在识别延迟和服务端负载之间比较均衡。如果网络环境差,可以放宽到 60ms 一帧,但首字延迟会增加,需要权衡。

我踩过的最大一次翻车,就是把所有精力放在组件样式上,结果接口还没验证。后来再接手这类项目,我都会先让 Node 脚本跑通一整段会议录音,确认最终结果完整无误,才允许自己碰 Vue 代码。这个习惯保证了我每次接入流式语音服务都能在一两天内看到真实字幕滚动起来。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进