Vue项目实时语音转文字:WebSocket鉴权、PCM重采样到结果渲染全解析

简介:科大讯飞实时语音转文字Vue.js版本是一份面向前端开发者的集成示例项目,基于Vue.js调用科大讯飞WebAPI,覆盖会议记录、在线教育、直播互动等实时语音转写场景。资源包共8个文件,含7个JavaScript脚本与1个HTML入口页面,RAR压缩包仅16KB;JS文件包括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}", algorithm="hmac-sha256", headers="host 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。这个方法返回一个 Promise,resolve 之后得到 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;rate=16000', 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;rate=16000'要和前面重采样的参数严格一致。每隔 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()) // 新的鉴权URL,date必须重新生成 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}", algorithm="hmac-sha256", headers="host date request-line", signature="${signature}"` const url = `wss://iat-api.xfyun.cn/v2/iat?authorization=${base64url(auth)}&date=${encodeURIComponent(date)}&host=iat-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 是否新鲜、重采样是否生效、断线重连是否带退避、卸载清理是否完整,每一步都强制过一遍才敢提测。希望这套拆解能帮你在自己项目里少走几步弯路。

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

相关新闻

协同过滤在白酒销售系统中的落地:SpringBoot2+Vue3全栈实战

协同过滤在白酒销售系统中的落地:SpringBoot2+Vue3全栈实战

做白酒销售系统的推荐,一开始我是有点怀疑的。白酒消费和买衣服、买书完全不一样,用户的口味极其固定,喝惯浓香的人不太可能因为推荐就切到酱香。那协同过滤算法在这种场景里到底能干什么?等我把整套系统过完一遍才明白&#xff0…

2026/10/21 1:32:21 阅读更多 →
vue3-element-admin 消息提示实战:ElMessage 与 Notification 通知中心怎么选,推送链路怎么跑通

vue3-element-admin 消息提示实战:ElMessage 与 Notification 通知中心怎么选,推送链路怎么跑通

vue3-element-admin 消息提示实战:ElMessage 与 Notification 通知中心怎么选,推送链路怎么跑通 【免费下载链接】vue3-element-admin 🔥基于 Vue 3 Vite 7 TypeScript element-plus 构建的后台管理前端模板(配套后端源码&#…

2026/10/21 1:31:17 阅读更多 →
esp_sensorless_bldc_control 比较器反电势过零检测与六步换相源码解读

esp_sensorless_bldc_control 比较器反电势过零检测与六步换相源码解读

esp_sensorless_bldc_control 比较器反电势过零检测与六步换相源码解读 【免费下载链接】esp-iot-solution Espressif IoT Library. IoT Device Drivers, Documentations and Solutions. 项目地址: https://gitcode.com/GitHub_Trending/es/esp-iot-solution 这篇文章以…

2026/10/21 1:31:17 阅读更多 →

最新新闻

AI辅助STM32开发实战:从提示词设计到代码审查的完整协同流程

AI辅助STM32开发实战:从提示词设计到代码审查的完整协同流程

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/21 2:07:44 阅读更多 →
ZCC485高速超低功耗RS485芯片实战解析

ZCC485高速超低功耗RS485芯片实战解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/21 2:07:44 阅读更多 →
YiShaAdmin部署避坑指南:10分钟修好启动与连接问题

YiShaAdmin部署避坑指南:10分钟修好启动与连接问题

YiShaAdmin部署避坑指南:10分钟修好启动与连接问题 【免费下载链接】YiShaAdmin 基于 .NET Core MVC 的权限管理系统,代码易读易懂、界面简洁美观 项目地址: https://gitcode.com/GitHub_Trending/yi/YiShaAdmin 大多数人卡在两处:dot…

2026/10/21 2:07:44 阅读更多 →
canmatrix解析DBC文件速度差十倍?字节相同但性能迥异的根因与排查

canmatrix解析DBC文件速度差十倍?字节相同但性能迥异的根因与排查

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/21 2:07:44 阅读更多 →
Robomaster硬件基础实战指南:从冒烟故障到系统级校准

Robomaster硬件基础实战指南:从冒烟故障到系统级校准

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/21 2:07:44 阅读更多 →
Sicar-Training-show-V16工业标准程序复习指南:架构、模块化与易错点

Sicar-Training-show-V16工业标准程序复习指南:架构、模块化与易错点

简介:Sicar-Training-show-V16程序是一套面向工业自动化领域学习者的Sicar标准程序训练示例包,适合正在接触西门子PLC编程、PROFINET通信及分布式IO组态的工程师与院校学生,用于理解标准化程序框架与设备描述文件的组织方式。压缩包为rar格式…

2026/10/21 2:06:44 阅读更多 →

日新闻

统一内存+量化:锐龙AI Max PRO 400如何本地跑320B大模型

统一内存+量化:锐龙AI Max PRO 400如何本地跑320B大模型

这段时间硬件圈和AI圈都在传一个事:AMD锐龙AI Max PRO 400系列把最高192GB统一内存搬进了PC形态的产品里,按宣传口径,本地跑320B参数的大家伙不再是纸面概念。我第一反应倒不是“内存真大”,而是这话背后的含金量——它把过去只能…

2026/10/21 0:00:11 阅读更多 →
AI助手深度使用体验:对话质量、长文本处理与指令遵循如何留住用户

AI助手深度使用体验:对话质量、长文本处理与指令遵循如何留住用户

1. 从“封号重开”这个行为说起:用户到底在追求什么“封号重开”这四个字,放在任何产品语境里都带着一股决绝的味道。一个账号从零开始积累使用记录、调教偏好、沉淀工作流,背后是大量的时间和精力成本。正常情况下,没有人愿意主动…

2026/10/21 0:00:11 阅读更多 →
ROS中YOLOv3工程落地:通信同步、坐标变换与性能调优

ROS中YOLOv3工程落地:通信同步、坐标变换与性能调优

1. 为什么在ROS里硬刚YOLOv3不是“炫技”,而是工程落地的必经之路我第一次在某高校机器人实验室看到那台搭载Jetson TX2的移动底盘原地打转、反复识别错垃圾桶时,就意识到:单纯跑通darknet_ros demo和真正让YOLOv3在ROS里“干活”&#xff0c…

2026/10/21 0:00:11 阅读更多 →

周新闻

基于GMM的语音识别系统原理与MATLAB实现详解

基于GMM的语音识别系统原理与MATLAB实现详解

说到基于高斯混合模型(GMM)的语音识别系统,很多人第一反应就是一堆数学公式,觉得门槛很高。但如果你真的在MATLAB里把它落地跑通,会发现核心其实就是三件事:把声音变成特征向量,用GMM给每个类别…

2026/10/20 10:02:13 阅读更多 →
数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理这四个字听起来挺唬人,但落到具体操作上,无非就是让计算机按你的想法“摆弄”像素。我在一个模拟项目X里用了一套特别经典的基础处理流程:对图像做反白、改调色板、彩色转灰度,最后把像素数据导出成txt文件。单看每一…

2026/10/20 20:43:59 阅读更多 →
机器人大脑技术盘点:具身智能的感知、决策与数据闭环

机器人大脑技术盘点:具身智能的感知、决策与数据闭环

如果你的信息流最近也天天刷到“机器人大脑”这个词,你会发现一个很有意思的现象:每个人说的“大脑”根本不是同一个东西。有人指的是大语言模型,有人指的是运动控制系统,还有人指的是能理解物理世界的端到端模型。在2026年这个时…

2026/10/20 2:50:50 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 10:02:08 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 9:50:17 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 9:50:45 阅读更多 →