前阵子我在给一个内部工具加“实时转写”功能,要把对话语音实时转成文字用于内容分析。调研了一圈,最后选了腾讯云 ASR 实时语音识别。原本以为就是“拿麦克风录音,发到 WebSocket,收文本”三件事,实际做下来发现每个环节都有文档里一笔带过、但实操能卡你半天的细节:签名怎么拼、音频格式选什么、二进制帧怎么发、浏览器录音怎么降采样、连接怎么优雅地结束。如果你正准备在网页前端接入腾讯云 ASR 实时语音识别,这篇文章就把整条链路拆开揉碎讲清楚,从账号准备、签名鉴权、录音采集、WebSocket 通信,到常见报错排查和避坑经验,全部按实操顺序过一遍。1. 接入前的准备与整体思路1.1 实时语音识别能解决什么问题实时语音识别和“录音文件识别”是两回事。录音文件识别是你上传一整段音频,服务端异步转写,几十秒后拿结果,适合会议录音复盘、音视频字幕这类场景。而实时语音识别 ASR 是通过 WebSocket 长连接不断推流音频,服务端边收边识别,并且在结果还没完整成形时就会推送临时识别文本,适合做语音输入法、实时字幕、客服质检、直播弹幕生成这类需要低延迟交互的产品。我这次要做的是客服对话实时质检:坐席和用户说话的同时,页面就要把当前的语句转成文字,方便后续命中规则提醒。这种场景对实时性要求很高,不能等整段话说完了再出结果,必须“边说边出字”,所以直接锁定实时语音识别,没有考虑文件识别方案。如果你是想在浏览器里实现类似功能,注意判断一下自己的场景:如果几十秒延迟可以接受,文件识别更省钱也更简单;如果必须在说话过程中持续输出文本,那就需要走实时语音识别 WebSocket 这条链路。1.2 账号开通与密钥准备接入腾讯云 ASR 之前,先要完成三件事。第一,注册腾讯云账号并完成实名认证。这个没什么好说的,按流程走即可,个人实名也能试用。第二,在控制台找到“语音识别”产品,开通“实时语音识别”服务。开通本身一般不需要审核,但要注意有些功能可能有地域或者套餐限制,页面提示什么就按什么操作就行。第三,在访问管理 CAM 里创建 API 密钥,得到一对 SecretId 和 SecretKey。这个密钥要放在后端,绝不能写进前端代码里。我见过有人为了图方便,把 SecretKey 直接放在前端静态配置里,这是非常危险的做法。因为 SecretKey 一旦泄露,别人就可以拿你的身份调用各种云 API,产生的费用和高风险都由你来承担。另外,实时语音识别是有并发限制的。默认的并发路数通常不算高,如果业务量上来,比如一个页面上同时开着多个识别连接,很容易触发并发限制报错。建议在接入前先确认下自己的并发配额,并和后端同事约定好限流和排队策略。1.3 技术选型:为什么直接走 WebSocket实时语音识别的接入方案,通常有两种常见选型。第一种是前端直接把麦克风音频通过 WebSocket 推到腾讯云 ASR 指定的地址,后端只负责生成带签名的 URL。这个方案链路短、延迟低,实时性最好。第二种是前端推流到自己的后端服务,后端再对接腾讯云 ASR。这个方案适合需要统一鉴权、统一全链路日志、未来可能要切换多家语音服务商的团队,但延迟会多一跳,而且后端要处理音频流的转发和缓冲,开发和运维成本更高。我这次的内部工具选择了第一种:后端只提供一个“获取 WebSocket URL”的接口,其余音频采集、连接管理、结果解析全部由前端完成。好处是实时性最高,代码逻辑集中在前端,出现问题也方便排查。这里有个细节要注意:WebSocket URL 是有有效期的,一般签名里带 expired 参数,过期后连接会被拒绝。所以前端不要缓存这个 URL 太久,建议每次建立连接前都向后端请求一次新的签名地址。2. 核心细节解析与实操要点2.1 签名算法与请求 URL 的坑腾讯云的实时语音识别 WebSocket 接口,鉴权方式不是在 Header 里塞 Token,而是把签名参数拼在 URL 的 Query 里。后端在生成 URL 时,需要准备这些参数:secretid:你的 SecretIdtimestamp:当前 Unix 时间戳,单位秒expired:签名的过期时间戳,一般取“当前时间 + 连接寿命”,比如当前时间加 1 小时nonce:随机数,用来增加签名的随机性signature:最终签名值签名的核心算法是 HMAC-SHA1。需要把参数按一定规则拼接成签名字符串,再用 SecretKey 做 HMAC-SHA1 计算,最后对结果做 Base64 编码。具体的拼接规则,腾讯云官方文档写得很清楚,但我实际操作中的体会是:字符串拼接顺序、密钥编码、Base64 输出的细节,有一个地方不对,连接就会报“签名错误”。我用 Node.js 生成签名 URL 时,参考的流程是这样的:const crypto = require('crypto'); function buildAsrWebSocketUrl({ secretId, secretKey, appId, expiredDuration = 3600 }) { const timestamp = Math.floor(Date.now() / 1000); const expired = timestamp + expiredDuration; const nonce = Math.floor(Math.random() * 0xffffffff); const service = 'asr'; // 1. 构造签名原文 const signStr = `secretid=${secretId}×tamp=${timestamp}&expired=${expired}&nonce=${nonce}`; // 2. 用 SecretKey 对原文做 HMAC-SHA1 const signature = crypto .createHmac('sha1', secretKey) .update(Buffer.from(signStr, 'utf8')) .digest('base64'); // 3. 拼接最终 URL(Domain 以官方文档为准) const query = new URLSearchParams({ secretid: secretId, timestamp, expired, nonce, signature: encodeURIComponent(signature), }); return `wss://${service}.example.com/asr/v2/${appId}?${query.toString()}`; }注意把asr.example.com替换成官方文档中的实际 WebSocket 域名。至于签名规则本身,我建议你写完后用一个测试音频连接一次,能连上就说明签名没问题。签名这种东西,看文档容易觉得自己会了,一跑就错,必须实际验证。还有个大坑是 URL 编码。signature 经过 Base64 后可能带+、/、=这些字符,直接拼进 URL 会被解析错乱,导致鉴权失败。所以拼接 URL 前一定要用encodeURIComponent对签名值做编码。这个细节在文档里容易忽略,但没做的话几乎百分之百报签名错。2.2 音频编码与采样率选择实时语音识别对音频格式有硬性要求。腾讯云 ASR 的引擎通常要求:采样率:16000 Hz编码:PCM,s16le 位深声道:单声道也就是说,你要传给服务端的音频必须是“16kHz、16bit、单声道 PCM”的裸流。为什么是 16kHz?因为识别引擎本身是基于 16k 采样率训练和优化的,这是“标准电话/语音”采样率,对语音识别来说既够用又省带宽。有人会想,那我把 44.1kHz 的高音质音频直接传上去,信息量更多,是不是识别得更准?实际效果往往是反的,因为引擎在预处理时会做重采样,而重采样后再压缩到 16k 并没有额外收益,甚至可能造成频谱混叠影响效果。所以别整花活,老老实实传 16k 单声道 PCM。浏览器麦克风采集到的原始音频采样率通常不是 16k,而是设备默认的 44.1kHz 或 48kHz。所以前端必须在本地把采样率降到 16000,再把 PCM 数据发给 WebSocket。这里有两个可选方案:第一个方案是用 AudioContext 的sampleRate参数指定输出采样率。但这个参数在部分浏览器并不生效,有些浏览器会忽视它,仍按设备默认采样率工作,所以不能完全依赖。第二个方案是采集到 PCM 后在 JavaScript 里手动做线性插值降采样。这种做法最可控、兼容性最好。我后面会给出具体代码。还有一个格式问题:不要直接把麦克风采集到的数据转成 MP3 或 AAC 再发上去。实时语音识别要的是裸 PCM,不是压缩音频。压缩格式不仅需要额外转码,延迟还会明显增加,识别率也可能下降。除非你的场景对带宽特别敏感,而且服务端明确支持某种压缩格式,否则就传 PCM。2.3 WebSocket 数据帧设计连接建立之后,音频数据不是一股脑全发过去,而是要切成小块,分帧推送。这样做的好处是服务端可以边收边处理,不用等全部数据到达。腾讯云 ASR 的 WebSocket 接口对消息类型有明确约定:音频数据用二进制帧发送,内容是一小块 PCM 数据控制消息用文本帧发送,内容是一个 JSON 字符串,比如结束识别时的{"type": "end"}每个音频分片的大小我建议按 80ms~100ms 来切。为什么是这个范围?太小的分片比如 20ms,会产生大量 WebSocket 帧,网络开销增大;太大的分片比如 500ms,会让服务端迟迟拿不到后面的话,识别延迟变大,不够“实时”。实测下来,100ms 左右是一个比较舒服的平衡点,也就是每 100ms 推一次。100ms 的 16k 单声道 PCM,数据量是 16000 × 2(字节) × 0.1 = 3200 字节,约 3.1KB,这个大小在 WebSocket 帧里非常轻量。整个识别过程的持续时间也要在初始化参数里通过voice_id这类字段做关联。每当一段话结束,服务端会返回final=1的完整结果;如果在语音过程中停顿,服务端可能先返回临时识别结果final=0,后续继续更新。前端拿到final=1时应该把这段文本固定下来,而final=0的结果只做动态展示。结束识别的流程也要处理好。你不能直接关闭 WebSocket,正确顺序是:发送一个文本帧,内容为结束标志等服务端返回最终结果(有时是一段完整的最终文本)关闭 WebSocket 连接如果不等最终结果就直接 close,最后半句话很可能就丢了。3. 手把手实现前端接入3.1 后端生成签名 URL前面说了,SecretKey 不能放前端,所以签名这一步放在后端的小接口里。我这边用 Node.js 实现了一个非常简单的接口,前端调用它就能拿到一个可用的 WebSocket URL。伪代码如下:// server.js const express = require('express'); const crypto = require('crypto'); const app = express(); app.get('/api/asr-url', (req, res) => { const { SECRET_ID, SECRET_KEY, APP_ID } = process.env; const timestamp = Math.floor(Date.now() / 1000); const expired = timestamp + 3600; const nonce = Math.floor(Math.random() * 0xffffffff); const signStr = `secretid=${SECRET_ID}×tamp=${timestamp}&expired=${expired}&nonce=${nonce}`; const signature = crypto.createHmac('sha1', SECRET_KEY) .update(Buffer.from(signStr, 'utf8')) .digest('base64'); const base = 'wss://asr.example.com/asr/v2/' + APP_ID; const url = `${base}?secretid=${encodeURIComponent(SECRET_ID)}×tamp=${timestamp}&expired=${expired}&nonce=${nonce}&signature=${encodeURIComponent(signature)}`; res.json({ url }); }); app.listen(3000);要注意几个点:不要把 SecretKey 硬编码在前端项目里,环境变量或密钥管理系统都行。expired 不能设得太短,否则用户打开页面还没开始说话,URL 就过期了;也不能太长,减少泄露风险。我一般设 1 小时。这个接口建议加一层业务鉴权,否则任何人都能拿到你的签名 URL,可能会造成盗用。前端拿到这个 URL 后直接 new WebSocket 即可。3.2 浏览器麦克风采集与降采样先获取麦克风权限,拿到原始的音频流,然后通过 AudioWorklet 或 ScriptProcessor 拿到 PCM 数据。ScriptProcessor 虽然官方标记为废弃,但在很多业务代码里仍然能稳定运行,而且代码量小,适合快速跑通流程。如果追求更稳定的性能,可以换 AudioWorklet,但代码复杂度会高一点。我这里的示例用简洁的思路:getUserMedia 拿到音频流,创建 AudioContext,用 ScriptProcessorNode 接收原始音频帧,再手动把采样率从 48k 或 44.1k 降到 16k。降采样的核心做法是线性插值:假设原始采样率是 48000,目标采样率是 16000,比例是 1/3。最朴素的做法是“抽点”,也就是每 3 个点取 1 个点。这样做简单,但容易有轻微失真。稍微好一点的是线性插值,根据小数位置的偏移量在两个原始采样点之间插值。实际识别场景下,抽点已经能用了,但插值法更稳,尤其是对于语音的尾音细节,插值法的过渡更自然。下面是一个简化但可直接使用的降采样函数:function downsampleTo16k(buffer, inputSampleRate) { const targetRate = 16000; const ratio = inputSampleRate / targetRate; const newLength = Math.round(buffer.length / ratio); const result = new Float32Array(newLength); // 线性插值降采样 for (let i = 0; i < newLength; i++) { const position = i * ratio; const index = Math.floor(position); const fraction = position - index; const left = buffer[index] || 0; const right = buffer[index + 1] || 0; result[i] = left + (right - left) * fraction; } return result; }拿到降采样后的 Float32Array,还要转换成 16bit PCM 的 Int16Array,因为服务端要求 pcm_s16le: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 new Int16Array(view); }把 Int16Array 的底层 ArrayBuffer 直接作为 WebSocket 二进制帧的 payload 发送即可。这里有个易错点:Float32Array 的字节序和 Int16 的转换,不能忽略。浏览器默认使用小端字节序,AudioContext 输出的 Float32 也是小端。在 PCM 转码时必须用小端字节序写,也就是setInt16的第三个参数传true。如果传了false用大端,服务端收到的就是乱码,识别结果会一片混乱,而且没有明显报错,非常难排查。3.3 WebSocket 连接与音频流推送建立一个 WebSocket 客户端,核心逻辑包括:在onopen时开始推流。每次采集到音频分片,就通过ws.send(pcmData)以二进制帧方式发送。在onmessage里解析返回的 JSON,更新页面上的转写结果。在用户手动停止或异常断开时发送结束消息再关闭。我习惯用一个大类来管理连接状态和音频采集:class AsrClient { constructor({ url, onResult, onStateChange }) { this.url = url; this.onResult = onResult; this.onStateChange = onStateChange; this.ws = null; this.audioContext = null; this.stream = null; this.isRecording = false; } async start() { // 1. 建立 WebSocket this.ws = new WebSocket(this.url); this.ws.binaryType = 'arraybuffer'; this.ws.onopen = async () => { this.onStateChange('connected'); // 2. 开始录音 await this.startMic(); }; this.ws.onmessage = (event) => { const data = JSON.parse(event.data); if (data.final === 1) { this.onResult({ final: true, text: data.text }); } else { this.onResult({ final: false, text: data.text }); } }; this.ws.onclose = () => { this.onStateChange('closed'); this.cleanup(); }; } async startMic() { this.stream = await navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true, }, }); this.audioContext = new AudioContext(); const source = this.audioContext.createMediaStreamSource(this.stream); // ScriptProcessor 简易方案,实际可用 AudioWorklet 替代 const processor = this.audioContext.createScriptProcessor(4096, 1, 1); const inputSampleRate = this.audioContext.sampleRate; processor.onaudioprocess = (e) => { if (!this.isRecording || !this.ws || this.ws.readyState !== WebSocket.OPEN) return; const float32Data = e.inputBuffer.getChannelData(0); const downsampled = downsampleTo16k(float32Data, inputSampleRate); const pcmData = floatTo16BitPCM(downsampled); this.ws.send(pcmData.buffer); }; source.connect(processor); processor.connect(this.audioContext.destination); } stop() { this.isRecording = false; // 1. 发送结束标志 if (this.ws && this.ws.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify({ type: 'end' })); } // 2. 等待服务端最终结果后关闭 setTimeout(() => { if (this.ws) { this.ws.close(); } }, 500); } cleanup() { if (this.stream) { this.stream.getTracks().forEach((track) => track.stop()); } if (this.audioContext) { this.audioContext.close(); } } }几个关键点我单独说明。createScriptProcessor(4096, 1, 1)里的 buffer size 选 4096,在多数设备上单次回调大约 85ms 左右,正好接近前面说的切片大小,推流频率比较合理。如果 buffer size 太小,回调会非常频繁,CPU 占用高;太大则延迟变大。processor.connect(this.audioContext.destination)这一行很多人会省略。如果不把 processor 连接到 destination,部分浏览器会认为这个节点没有被使用,从而不触发onaudioprocess回调。这里连接 destination 并不需要真的把声音放出来,只是让节点保持活跃,属于一个容易踩的坑。echoCancellation和noiseSuppression建议开启。客服对话场景里背景噪声通常不大,但开启降噪能明显提升识别率。需要注意的是,这两个属性受浏览器实现影响,不是百分之百生效,但开启总比不开好。停止录音时的处理顺序,前面的代码里已经体现了。先发送 JSON 结束消息,再延时一小段时间关 WebSocket。这个延时不能省,服务端收到结束消息后还要做最后的识别收尾,如果你立刻 close,可能收不到最终结果。3.4 界面交互与状态管理页面上的状态需要给用户足够的反馈。我通常用一个简单的状态机管理:idle:初始状态,未连接connecting:正在建立 WebSocketconnected:连接已建立,正在识别closing:正在等待最终结果closed:已关闭每个状态对应按钮的可用状态。比如:idle:可以点击“开始识别”connected:可以点击“停止识别”,同时显示实时文本connecting / closing:两个按钮都不可用,避免并发操作这套状态机看着简单,但如果没有明确管理,很容易在用户快速点击“开始/停止”时出现 WebSocket 连接泄露或者多路连接并存的问题。我在实际项目中就踩过:快速点两下“开始”,结果创建了两个 WebSocket 连接,旧的没关掉,服务端并发连接数瞬间超限,直接报错。所以代码里一定要在start()最开始检查,如果当前不是 idle 状态,直接 return。同时要把 ws 引用置空判断,防止重复初始化。4. 常见问题与避坑实录4.1 签名/连接类错误速查下面是我实际遇到过的错误及排查方向,做成一个速查表。现象可能原因排查与解决办法连接被拒绝,提示鉴权失败signature 计算错误,或者 URL 里签名参数被转义破坏检查签名原文拼接顺序,确认 signature 已 encodeURIComponent连接被拒绝,提示过期timestamp 或 expired 异常,通常是服务器和客户端时间不同步用后端服务器时间生成,不要用用户浏览器时间连接成功后立刻关闭nonce 重复或 SecretKey 不匹配检查 nonce 是否每次生成新的,SecretKey 是否填错偶发连接失败并发连接数超过限制查看调用后端接口的 QPS 情况,加限流或排队我在联调阶段遇到最多的问题就是签名。后来我总结出一个非常有效的排查顺序:先打印出完整的 WebSocket URL,用一个普通的 HTTPS 客户端(比如 Postman)尝试连接,能连上说明 URL 本身没问题,再跑到浏览器里调试。这样能把问题快速分成“URL 生成错误”和“浏览器环境错误”两类。4.2 浏览器录音采集常见问题麦克风权限的问题是前端最容易遇到的。第一,实时语音识别必须运行在 HTTPS 环境下,除非你在本地 localhost 调试。浏览器在非安全环境下会直接禁用 getUserMedia。这个限制不是腾讯云强加的,是浏览器安全策略,所以部署时一定要确认线上环境是 HTTPS。第二,用户第一次点击“开始识别”时,浏览器会弹出麦克风授权框。如果用户拒绝了授权,getUserMedia 会抛出一个 NotAllowedError。此时页面应该提示用户去浏览器设置里重新开启权限,而不是傻傻地反复请求。第三,麦克风授权后,偶尔会出现打开页面后拿到的音频流是静音的情况。这通常是操作系统层面的麦克风设备被占用,或者用户选了错误的输入设备。可以在界面上做一个简单的音量指示器,如果采集到的音量一直为 0,主动提示用户检查麦克风设备。第四,ScriptProcessor 在 Safari 上的表现和 Chrome 差异较大。如果目标用户大量使用 Safari,建议尽早切到 AudioWorklet 方案,或者用现成的录音库封装一层。这不是不能跑,而是兼容性成本要提前评估。4.3 识别效果相关的坑连上了、有音频流了、也不报错了,但识别结果不对,这时候问题往往出在音频链路或策略配置。最常见的是识别结果丢尾字。表现是用户说完一句话,识别文本总是少了最后一个字或几个字。我遇到这个问题的原因是停止识别的流程不对:用户停止录音后我立刻 close 了 WebSocket,服务端还没收到结束消息,最后一段音频没有完成识别就被截断了。解决方法是先发结束 JSON,等最终的 final 结果返回后再 close。第二个常见问题是“说了很久的话,页面上一直只有临时结果”。这大概率不是 bug,而是服务端在做静音检测和断句。实时识别会先输出临时结果,只有当检测到足够的停顿后才会输出 final 结果。如果你希望“用户一停就立刻出最终结果”,可以调整参数让服务端更敏感,但副作用是正常语速下句子也可能被切碎。这块需要结合实际业务体验反复调。第三个问题是噪声环境识别率低。建议如果页面本身不需要把声音外放,可以在采集端做更多的降噪处理,或者在 audio 约束里开启 noiseSuppression。但要注意,过度降噪可能把语音也削掉一部分,尤其是轻声说话的内容。我的经验是优先保证安静环境,再考虑算法降噪。4.4 并发与稳定性实时语音识别的 WebSocket 连接是长连接,一个页面一路识别就占用一个连接额度。如果同一个用户开两个浏览器标签页,同时跑识别,非常容易触发并发限制。我在内测时遇到过一次比较隐蔽的并发问题:不是同时开多个页面,而是前一个 WebSocket 没有真正关闭,新页面的连接又被创建,导致额度被旧连接占满。排查后发现是页面关闭时没有监听beforeunload事件去主动关闭 WebSocket,服务端那边还挂着旧连接。后来加了一行window.addEventListener('beforeunload', () => ws.close())解决。网络方面也要处理断线重连。移动端网络波动是常态,如果 WebSocket 断开,应当保留已识别的文本,并在合适时机重新建立连接继续识别。重连时建议使用新的 voice_id,避免服务端把前后两段音频当成同一段来处理。4.5 调试技巧最好用的调试工具就是浏览器开发者工具的 Network 面板。在 WS 过滤器下可以看到 WebSocket 连接的所有二进制帧和文本帧。如果发现发出去的二进制帧大小异常,比如非常大或非常小,就能快速判断是录音切片逻辑出错还是降采样没生效。另一个技巧是先用现成音频文件测试,再上麦克风。我建议你先把整个链路用一段 16k 单声道的 PCM 文件跑通,确认签名、连接、推流、收结果都没问题,然后再切换到浏览器麦克风采集。这样能把问题范围大大缩小。实际调试时,我习惯在 WebSocket 的 onmessage 里打印原始 JSON,包括临时结果和最终结果。如果发现服务端返回的字段结构里没有text,就要去查一下最新的接口协议,字段名可能随版本调整。跟随官方文档更新很重要,不能照搬老代码。最后分享一个小经验:正式上线前,要给实时识别增加一个超时断连机制。如果用户打开页面后一直不说话,但 WebSocket 还占着连接,超过一定时间应该主动断开,否则连接额度会被白白浪费。这个逻辑看似简单,不加的话在一个多人共用的内部系统里,很容易把并发限额打满。
前阵子我在给一个内部工具加“实时转写”功能,要把对话语音实时转成文字用于内容分析。调研了一圈,最后选了腾讯云 ASR 实时语音识别。原本以为就是“拿麦克风录音,发到 WebSocket,收文本”三件事,实际做下来发现每个环节都有文档里一笔带过、但实操能卡你半天的细节:签名怎么拼、音频格式选什么、二进制帧怎么发、浏览器录音怎么降采样、连接怎么优雅地结束。如果你正准备在网页前端接入腾讯云 ASR 实时语音识别,这篇文章就把整条链路拆开揉碎讲清楚,从账号准备、签名鉴权、录音采集、WebSocket 通信,到常见报错排查和避坑经验,全部按实操顺序过一遍。1. 接入前的准备与整体思路1.1 实时语音识别能解决什么问题实时语音识别和“录音文件识别”是两回事。录音文件识别是你上传一整段音频,服务端异步转写,几十秒后拿结果,适合会议录音复盘、音视频字幕这类场景。而实时语音识别 ASR 是通过 WebSocket 长连接不断推流音频,服务端边收边识别,并且在结果还没完整成形时就会推送临时识别文本,适合做语音输入法、实时字幕、客服质检、直播弹幕生成这类需要低延迟交互的产品。我这次要做的是客服对话实时质检:坐席和用户说话的同时,页面就要把当前的语句转成文字,方便后续命中规则提醒。这种场景对实时性要求很高,不能等整段话说完了再出结果,必须“边说边出字”,所以直接锁定实时语音识别,没有考虑文件识别方案。如果你是想在浏览器里实现类似功能,注意判断一下自己的场景:如果几十秒延迟可以接受,文件识别更省钱也更简单;如果必须在说话过程中持续输出文本,那就需要走实时语音识别 WebSocket 这条链路。1.2 账号开通与密钥准备接入腾讯云 ASR 之前,先要完成三件事。第一,注册腾讯云账号并完成实名认证。这个没什么好说的,按流程走即可,个人实名也能试用。第二,在控制台找到“语音识别”产品,开通“实时语音识别”服务。开通本身一般不需要审核,但要注意有些功能可能有地域或者套餐限制,页面提示什么就按什么操作就行。第三,在访问管理 CAM 里创建 API 密钥,得到一对 SecretId 和 SecretKey。这个密钥要放在后端,绝不能写进前端代码里。我见过有人为了图方便,把 SecretKey 直接放在前端静态配置里,这是非常危险的做法。因为 SecretKey 一旦泄露,别人就可以拿你的身份调用各种云 API,产生的费用和高风险都由你来承担。另外,实时语音识别是有并发限制的。默认的并发路数通常不算高,如果业务量上来,比如一个页面上同时开着多个识别连接,很容易触发并发限制报错。建议在接入前先确认下自己的并发配额,并和后端同事约定好限流和排队策略。1.3 技术选型:为什么直接走 WebSocket实时语音识别的接入方案,通常有两种常见选型。第一种是前端直接把麦克风音频通过 WebSocket 推到腾讯云 ASR 指定的地址,后端只负责生成带签名的 URL。这个方案链路短、延迟低,实时性最好。第二种是前端推流到自己的后端服务,后端再对接腾讯云 ASR。这个方案适合需要统一鉴权、统一全链路日志、未来可能要切换多家语音服务商的团队,但延迟会多一跳,而且后端要处理音频流的转发和缓冲,开发和运维成本更高。我这次的内部工具选择了第一种:后端只提供一个“获取 WebSocket URL”的接口,其余音频采集、连接管理、结果解析全部由前端完成。好处是实时性最高,代码逻辑集中在前端,出现问题也方便排查。这里有个细节要注意:WebSocket URL 是有有效期的,一般签名里带 expired 参数,过期后连接会被拒绝。所以前端不要缓存这个 URL 太久,建议每次建立连接前都向后端请求一次新的签名地址。2. 核心细节解析与实操要点2.1 签名算法与请求 URL 的坑腾讯云的实时语音识别 WebSocket 接口,鉴权方式不是在 Header 里塞 Token,而是把签名参数拼在 URL 的 Query 里。后端在生成 URL 时,需要准备这些参数:secretid:你的 SecretIdtimestamp:当前 Unix 时间戳,单位秒expired:签名的过期时间戳,一般取“当前时间 + 连接寿命”,比如当前时间加 1 小时nonce:随机数,用来增加签名的随机性signature:最终签名值签名的核心算法是 HMAC-SHA1。需要把参数按一定规则拼接成签名字符串,再用 SecretKey 做 HMAC-SHA1 计算,最后对结果做 Base64 编码。具体的拼接规则,腾讯云官方文档写得很清楚,但我实际操作中的体会是:字符串拼接顺序、密钥编码、Base64 输出的细节,有一个地方不对,连接就会报“签名错误”。我用 Node.js 生成签名 URL 时,参考的流程是这样的:const crypto = require('crypto'); function buildAsrWebSocketUrl({ secretId, secretKey, appId, expiredDuration = 3600 }) { const timestamp = Math.floor(Date.now() / 1000); const expired = timestamp + expiredDuration; const nonce = Math.floor(Math.random() * 0xffffffff); const service = 'asr'; // 1. 构造签名原文 const signStr = `secretid=${secretId}×tamp=${timestamp}&expired=${expired}&nonce=${nonce}`; // 2. 用 SecretKey 对原文做 HMAC-SHA1 const signature = crypto .createHmac('sha1', secretKey) .update(Buffer.from(signStr, 'utf8')) .digest('base64'); // 3. 拼接最终 URL(Domain 以官方文档为准) const query = new URLSearchParams({ secretid: secretId, timestamp, expired, nonce, signature: encodeURIComponent(signature), }); return `wss://${service}.example.com/asr/v2/${appId}?${query.toString()}`; }注意把asr.example.com替换成官方文档中的实际 WebSocket 域名。至于签名规则本身,我建议你写完后用一个测试音频连接一次,能连上就说明签名没问题。签名这种东西,看文档容易觉得自己会了,一跑就错,必须实际验证。还有个大坑是 URL 编码。signature 经过 Base64 后可能带+、/、=这些字符,直接拼进 URL 会被解析错乱,导致鉴权失败。所以拼接 URL 前一定要用encodeURIComponent对签名值做编码。这个细节在文档里容易忽略,但没做的话几乎百分之百报签名错。2.2 音频编码与采样率选择实时语音识别对音频格式有硬性要求。腾讯云 ASR 的引擎通常要求:采样率:16000 Hz编码:PCM,s16le 位深声道:单声道也就是说,你要传给服务端的音频必须是“16kHz、16bit、单声道 PCM”的裸流。为什么是 16kHz?因为识别引擎本身是基于 16k 采样率训练和优化的,这是“标准电话/语音”采样率,对语音识别来说既够用又省带宽。有人会想,那我把 44.1kHz 的高音质音频直接传上去,信息量更多,是不是识别得更准?实际效果往往是反的,因为引擎在预处理时会做重采样,而重采样后再压缩到 16k 并没有额外收益,甚至可能造成频谱混叠影响效果。所以别整花活,老老实实传 16k 单声道 PCM。浏览器麦克风采集到的原始音频采样率通常不是 16k,而是设备默认的 44.1kHz 或 48kHz。所以前端必须在本地把采样率降到 16000,再把 PCM 数据发给 WebSocket。这里有两个可选方案:第一个方案是用 AudioContext 的sampleRate参数指定输出采样率。但这个参数在部分浏览器并不生效,有些浏览器会忽视它,仍按设备默认采样率工作,所以不能完全依赖。第二个方案是采集到 PCM 后在 JavaScript 里手动做线性插值降采样。这种做法最可控、兼容性最好。我后面会给出具体代码。还有一个格式问题:不要直接把麦克风采集到的数据转成 MP3 或 AAC 再发上去。实时语音识别要的是裸 PCM,不是压缩音频。压缩格式不仅需要额外转码,延迟还会明显增加,识别率也可能下降。除非你的场景对带宽特别敏感,而且服务端明确支持某种压缩格式,否则就传 PCM。2.3 WebSocket 数据帧设计连接建立之后,音频数据不是一股脑全发过去,而是要切成小块,分帧推送。这样做的好处是服务端可以边收边处理,不用等全部数据到达。腾讯云 ASR 的 WebSocket 接口对消息类型有明确约定:音频数据用二进制帧发送,内容是一小块 PCM 数据控制消息用文本帧发送,内容是一个 JSON 字符串,比如结束识别时的{"type": "end"}每个音频分片的大小我建议按 80ms~100ms 来切。为什么是这个范围?太小的分片比如 20ms,会产生大量 WebSocket 帧,网络开销增大;太大的分片比如 500ms,会让服务端迟迟拿不到后面的话,识别延迟变大,不够“实时”。实测下来,100ms 左右是一个比较舒服的平衡点,也就是每 100ms 推一次。100ms 的 16k 单声道 PCM,数据量是 16000 × 2(字节) × 0.1 = 3200 字节,约 3.1KB,这个大小在 WebSocket 帧里非常轻量。整个识别过程的持续时间也要在初始化参数里通过voice_id这类字段做关联。每当一段话结束,服务端会返回final=1的完整结果;如果在语音过程中停顿,服务端可能先返回临时识别结果final=0,后续继续更新。前端拿到final=1时应该把这段文本固定下来,而final=0的结果只做动态展示。结束识别的流程也要处理好。你不能直接关闭 WebSocket,正确顺序是:发送一个文本帧,内容为结束标志等服务端返回最终结果(有时是一段完整的最终文本)关闭 WebSocket 连接如果不等最终结果就直接 close,最后半句话很可能就丢了。3. 手把手实现前端接入3.1 后端生成签名 URL前面说了,SecretKey 不能放前端,所以签名这一步放在后端的小接口里。我这边用 Node.js 实现了一个非常简单的接口,前端调用它就能拿到一个可用的 WebSocket URL。伪代码如下:// server.js const express = require('express'); const crypto = require('crypto'); const app = express(); app.get('/api/asr-url', (req, res) => { const { SECRET_ID, SECRET_KEY, APP_ID } = process.env; const timestamp = Math.floor(Date.now() / 1000); const expired = timestamp + 3600; const nonce = Math.floor(Math.random() * 0xffffffff); const signStr = `secretid=${SECRET_ID}×tamp=${timestamp}&expired=${expired}&nonce=${nonce}`; const signature = crypto.createHmac('sha1', SECRET_KEY) .update(Buffer.from(signStr, 'utf8')) .digest('base64'); const base = 'wss://asr.example.com/asr/v2/' + APP_ID; const url = `${base}?secretid=${encodeURIComponent(SECRET_ID)}×tamp=${timestamp}&expired=${expired}&nonce=${nonce}&signature=${encodeURIComponent(signature)}`; res.json({ url }); }); app.listen(3000);要注意几个点:不要把 SecretKey 硬编码在前端项目里,环境变量或密钥管理系统都行。expired 不能设得太短,否则用户打开页面还没开始说话,URL 就过期了;也不能太长,减少泄露风险。我一般设 1 小时。这个接口建议加一层业务鉴权,否则任何人都能拿到你的签名 URL,可能会造成盗用。前端拿到这个 URL 后直接 new WebSocket 即可。3.2 浏览器麦克风采集与降采样先获取麦克风权限,拿到原始的音频流,然后通过 AudioWorklet 或 ScriptProcessor 拿到 PCM 数据。ScriptProcessor 虽然官方标记为废弃,但在很多业务代码里仍然能稳定运行,而且代码量小,适合快速跑通流程。如果追求更稳定的性能,可以换 AudioWorklet,但代码复杂度会高一点。我这里的示例用简洁的思路:getUserMedia 拿到音频流,创建 AudioContext,用 ScriptProcessorNode 接收原始音频帧,再手动把采样率从 48k 或 44.1k 降到 16k。降采样的核心做法是线性插值:假设原始采样率是 48000,目标采样率是 16000,比例是 1/3。最朴素的做法是“抽点”,也就是每 3 个点取 1 个点。这样做简单,但容易有轻微失真。稍微好一点的是线性插值,根据小数位置的偏移量在两个原始采样点之间插值。实际识别场景下,抽点已经能用了,但插值法更稳,尤其是对于语音的尾音细节,插值法的过渡更自然。下面是一个简化但可直接使用的降采样函数:function downsampleTo16k(buffer, inputSampleRate) { const targetRate = 16000; const ratio = inputSampleRate / targetRate; const newLength = Math.round(buffer.length / ratio); const result = new Float32Array(newLength); // 线性插值降采样 for (let i = 0; i < newLength; i++) { const position = i * ratio; const index = Math.floor(position); const fraction = position - index; const left = buffer[index] || 0; const right = buffer[index + 1] || 0; result[i] = left + (right - left) * fraction; } return result; }拿到降采样后的 Float32Array,还要转换成 16bit PCM 的 Int16Array,因为服务端要求 pcm_s16le: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 new Int16Array(view); }把 Int16Array 的底层 ArrayBuffer 直接作为 WebSocket 二进制帧的 payload 发送即可。这里有个易错点:Float32Array 的字节序和 Int16 的转换,不能忽略。浏览器默认使用小端字节序,AudioContext 输出的 Float32 也是小端。在 PCM 转码时必须用小端字节序写,也就是setInt16的第三个参数传true。如果传了false用大端,服务端收到的就是乱码,识别结果会一片混乱,而且没有明显报错,非常难排查。3.3 WebSocket 连接与音频流推送建立一个 WebSocket 客户端,核心逻辑包括:在onopen时开始推流。每次采集到音频分片,就通过ws.send(pcmData)以二进制帧方式发送。在onmessage里解析返回的 JSON,更新页面上的转写结果。在用户手动停止或异常断开时发送结束消息再关闭。我习惯用一个大类来管理连接状态和音频采集:class AsrClient { constructor({ url, onResult, onStateChange }) { this.url = url; this.onResult = onResult; this.onStateChange = onStateChange; this.ws = null; this.audioContext = null; this.stream = null; this.isRecording = false; } async start() { // 1. 建立 WebSocket this.ws = new WebSocket(this.url); this.ws.binaryType = 'arraybuffer'; this.ws.onopen = async () => { this.onStateChange('connected'); // 2. 开始录音 await this.startMic(); }; this.ws.onmessage = (event) => { const data = JSON.parse(event.data); if (data.final === 1) { this.onResult({ final: true, text: data.text }); } else { this.onResult({ final: false, text: data.text }); } }; this.ws.onclose = () => { this.onStateChange('closed'); this.cleanup(); }; } async startMic() { this.stream = await navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true, }, }); this.audioContext = new AudioContext(); const source = this.audioContext.createMediaStreamSource(this.stream); // ScriptProcessor 简易方案,实际可用 AudioWorklet 替代 const processor = this.audioContext.createScriptProcessor(4096, 1, 1); const inputSampleRate = this.audioContext.sampleRate; processor.onaudioprocess = (e) => { if (!this.isRecording || !this.ws || this.ws.readyState !== WebSocket.OPEN) return; const float32Data = e.inputBuffer.getChannelData(0); const downsampled = downsampleTo16k(float32Data, inputSampleRate); const pcmData = floatTo16BitPCM(downsampled); this.ws.send(pcmData.buffer); }; source.connect(processor); processor.connect(this.audioContext.destination); } stop() { this.isRecording = false; // 1. 发送结束标志 if (this.ws && this.ws.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify({ type: 'end' })); } // 2. 等待服务端最终结果后关闭 setTimeout(() => { if (this.ws) { this.ws.close(); } }, 500); } cleanup() { if (this.stream) { this.stream.getTracks().forEach((track) => track.stop()); } if (this.audioContext) { this.audioContext.close(); } } }几个关键点我单独说明。createScriptProcessor(4096, 1, 1)里的 buffer size 选 4096,在多数设备上单次回调大约 85ms 左右,正好接近前面说的切片大小,推流频率比较合理。如果 buffer size 太小,回调会非常频繁,CPU 占用高;太大则延迟变大。processor.connect(this.audioContext.destination)这一行很多人会省略。如果不把 processor 连接到 destination,部分浏览器会认为这个节点没有被使用,从而不触发onaudioprocess回调。这里连接 destination 并不需要真的把声音放出来,只是让节点保持活跃,属于一个容易踩的坑。echoCancellation和noiseSuppression建议开启。客服对话场景里背景噪声通常不大,但开启降噪能明显提升识别率。需要注意的是,这两个属性受浏览器实现影响,不是百分之百生效,但开启总比不开好。停止录音时的处理顺序,前面的代码里已经体现了。先发送 JSON 结束消息,再延时一小段时间关 WebSocket。这个延时不能省,服务端收到结束消息后还要做最后的识别收尾,如果你立刻 close,可能收不到最终结果。3.4 界面交互与状态管理页面上的状态需要给用户足够的反馈。我通常用一个简单的状态机管理:idle:初始状态,未连接connecting:正在建立 WebSocketconnected:连接已建立,正在识别closing:正在等待最终结果closed:已关闭每个状态对应按钮的可用状态。比如:idle:可以点击“开始识别”connected:可以点击“停止识别”,同时显示实时文本connecting / closing:两个按钮都不可用,避免并发操作这套状态机看着简单,但如果没有明确管理,很容易在用户快速点击“开始/停止”时出现 WebSocket 连接泄露或者多路连接并存的问题。我在实际项目中就踩过:快速点两下“开始”,结果创建了两个 WebSocket 连接,旧的没关掉,服务端并发连接数瞬间超限,直接报错。所以代码里一定要在start()最开始检查,如果当前不是 idle 状态,直接 return。同时要把 ws 引用置空判断,防止重复初始化。4. 常见问题与避坑实录4.1 签名/连接类错误速查下面是我实际遇到过的错误及排查方向,做成一个速查表。现象可能原因排查与解决办法连接被拒绝,提示鉴权失败signature 计算错误,或者 URL 里签名参数被转义破坏检查签名原文拼接顺序,确认 signature 已 encodeURIComponent连接被拒绝,提示过期timestamp 或 expired 异常,通常是服务器和客户端时间不同步用后端服务器时间生成,不要用用户浏览器时间连接成功后立刻关闭nonce 重复或 SecretKey 不匹配检查 nonce 是否每次生成新的,SecretKey 是否填错偶发连接失败并发连接数超过限制查看调用后端接口的 QPS 情况,加限流或排队我在联调阶段遇到最多的问题就是签名。后来我总结出一个非常有效的排查顺序:先打印出完整的 WebSocket URL,用一个普通的 HTTPS 客户端(比如 Postman)尝试连接,能连上说明 URL 本身没问题,再跑到浏览器里调试。这样能把问题快速分成“URL 生成错误”和“浏览器环境错误”两类。4.2 浏览器录音采集常见问题麦克风权限的问题是前端最容易遇到的。第一,实时语音识别必须运行在 HTTPS 环境下,除非你在本地 localhost 调试。浏览器在非安全环境下会直接禁用 getUserMedia。这个限制不是腾讯云强加的,是浏览器安全策略,所以部署时一定要确认线上环境是 HTTPS。第二,用户第一次点击“开始识别”时,浏览器会弹出麦克风授权框。如果用户拒绝了授权,getUserMedia 会抛出一个 NotAllowedError。此时页面应该提示用户去浏览器设置里重新开启权限,而不是傻傻地反复请求。第三,麦克风授权后,偶尔会出现打开页面后拿到的音频流是静音的情况。这通常是操作系统层面的麦克风设备被占用,或者用户选了错误的输入设备。可以在界面上做一个简单的音量指示器,如果采集到的音量一直为 0,主动提示用户检查麦克风设备。第四,ScriptProcessor 在 Safari 上的表现和 Chrome 差异较大。如果目标用户大量使用 Safari,建议尽早切到 AudioWorklet 方案,或者用现成的录音库封装一层。这不是不能跑,而是兼容性成本要提前评估。4.3 识别效果相关的坑连上了、有音频流了、也不报错了,但识别结果不对,这时候问题往往出在音频链路或策略配置。最常见的是识别结果丢尾字。表现是用户说完一句话,识别文本总是少了最后一个字或几个字。我遇到这个问题的原因是停止识别的流程不对:用户停止录音后我立刻 close 了 WebSocket,服务端还没收到结束消息,最后一段音频没有完成识别就被截断了。解决方法是先发结束 JSON,等最终的 final 结果返回后再 close。第二个常见问题是“说了很久的话,页面上一直只有临时结果”。这大概率不是 bug,而是服务端在做静音检测和断句。实时识别会先输出临时结果,只有当检测到足够的停顿后才会输出 final 结果。如果你希望“用户一停就立刻出最终结果”,可以调整参数让服务端更敏感,但副作用是正常语速下句子也可能被切碎。这块需要结合实际业务体验反复调。第三个问题是噪声环境识别率低。建议如果页面本身不需要把声音外放,可以在采集端做更多的降噪处理,或者在 audio 约束里开启 noiseSuppression。但要注意,过度降噪可能把语音也削掉一部分,尤其是轻声说话的内容。我的经验是优先保证安静环境,再考虑算法降噪。4.4 并发与稳定性实时语音识别的 WebSocket 连接是长连接,一个页面一路识别就占用一个连接额度。如果同一个用户开两个浏览器标签页,同时跑识别,非常容易触发并发限制。我在内测时遇到过一次比较隐蔽的并发问题:不是同时开多个页面,而是前一个 WebSocket 没有真正关闭,新页面的连接又被创建,导致额度被旧连接占满。排查后发现是页面关闭时没有监听beforeunload事件去主动关闭 WebSocket,服务端那边还挂着旧连接。后来加了一行window.addEventListener('beforeunload', () => ws.close())解决。网络方面也要处理断线重连。移动端网络波动是常态,如果 WebSocket 断开,应当保留已识别的文本,并在合适时机重新建立连接继续识别。重连时建议使用新的 voice_id,避免服务端把前后两段音频当成同一段来处理。4.5 调试技巧最好用的调试工具就是浏览器开发者工具的 Network 面板。在 WS 过滤器下可以看到 WebSocket 连接的所有二进制帧和文本帧。如果发现发出去的二进制帧大小异常,比如非常大或非常小,就能快速判断是录音切片逻辑出错还是降采样没生效。另一个技巧是先用现成音频文件测试,再上麦克风。我建议你先把整个链路用一段 16k 单声道的 PCM 文件跑通,确认签名、连接、推流、收结果都没问题,然后再切换到浏览器麦克风采集。这样能把问题范围大大缩小。实际调试时,我习惯在 WebSocket 的 onmessage 里打印原始 JSON,包括临时结果和最终结果。如果发现服务端返回的字段结构里没有text,就要去查一下最新的接口协议,字段名可能随版本调整。跟随官方文档更新很重要,不能照搬老代码。最后分享一个小经验:正式上线前,要给实时识别增加一个超时断连机制。如果用户打开页面后一直不说话,但 WebSocket 还占着连接,超过一定时间应该主动断开,否则连接额度会被白白浪费。这个逻辑看似简单,不加的话在一个多人共用的内部系统里,很容易把并发限额打满。