简介:微信小程序语音识别项目是一套面向微信小程序开发者的完整工程示例,围绕科大讯飞语音识别接口展示语音转文字、实时语音输入与智能语音交互的实现思路,适合具备一定JavaScript基础、希望在小程序中快速接入AI语音能力的开发者学习。压缩包内共22个文件,大小约75KB,涵盖js逻辑代码、json配置、wxml页面结构、wxss样式及png图标素材,同时附带readme、txt、docx说明文档,目录结构清晰,便于按需查阅与二次开发。项目基于微信开发者工具搭建,完整呈现从前端代码到第三方接口调用的流程,读者可据此复现语音交互功能,并掌握小程序项目中语音处理的基本架构。已有107人下载学习,对于想要扩展会议记录、课堂笔记、语音搜索等交互场景的开发者而言,这是一份紧凑且可供直接参考的工程资料。
1. 微信小程序里的语音识别:为什么非要绕一圈接外部接口
在微信小程序里做语音转文字,第一反应往往是去找现成的插件,真正把录音流送进语音识别接口时才发现,微信生态并没有开箱即用的本地识别能力,浏览器里的 Web Speech API 在真机上不是被裁剪就是不支持。最稳妥、也是目前从业者用得最多的方案,是把小程序的录音帧通过 WebSocket 实时推给讯飞语音识别接口这类第三方服务,让云端把 PCM 音频流转成文字再回传。这篇文章就把这条路完整拆开:从科大讯飞语音识别接口的鉴权原理、小程序录音模块的搭建,到实时语音输入的帧流处理、结果解析,最后落到五个高频踩坑点和一套可复现的调试验证方法。
做这个方向的人一般是三类:正在开发小程序里语音搜索、语音输入法、会议纪要类功能的前端开发者,负责智能语音交互类产品 DEMO 验证的产品或独立开发者,以及想在小程序里快速接入语音转文字能力、又不想自己训练模型的团队。这套链路的技术核心其实只有两层:小程序端把麦克风采到的音频持续切成小段推出去,服务端把音频段识别成文本推回来。中间要解决的难点集中在音频格式对齐、鉴权签名不失败、长连接不中断、结果不错字这几件事上。下面按一条能跑通的最小链路来讲,每步都给可以直接抄的命令和代码片段。
2. 讯飞语音识别接口:协议选型与鉴权原理
2.1 WebSocket 与 HTTP:实时场景为什么要走长连接
语音识别接口通常提供两种接入方式:一种是类似 REST 的一次性提交,把整段音频 POST 上去等结果返回;另一种是 WebSocket 长连接,边录边传边收结果。标题里提到的实时语音输入,对应的一定是后者。因为语音转文字在交互场景里要的是“边说边出字”,如果等整段音频录完再上传,用户看着空白页面等三秒,体验就直接崩了。
我一般会先确认一件事:服务商给的 WebSocket 接口,上行是二进制音频帧,下行是 JSON 文本结果,还是上下行全是 JSON。讯飞语音识别接口在实时场景下是典型的“上行音频、下行文本”结构,连接建立后客户端连续发送 PCM 音频二进制帧,服务端逐帧返回中间识别结果,说到一个停顿点就返回一句最终结果。这个模型和麦克风采样天然匹配,不需要额外做流控,只要保证帧的到达间隔稳定即可。
HTTP 长轮询方案我没采用的原因还包括连接开销。小程序在真机上网络环境复杂,4G 和 Wi-Fi 切换、基站信号抖动都会让 POST 请求超时重发,而 WebSocket 一旦建连,网络抖动只影响当前帧是否到达,重发机制只要做在协议层,比 HTTP 层处理起来轻得多。
2.2 鉴权签名计算:时间戳、密钥与 URL 拼接顺序
讯飞这类云服务的接口鉴权一般不在 WebSocket 握手之后做,而是要求把鉴权参数直接放在握手 URL 上。常见做法是取当前 UTC 时间戳、接口密钥、随机数三个要素拼成签名。我在项目中看到最多人翻车的地方是时间戳:微信开发者工具里打印本地时间没问题,但手机系统时间如果被用户手动改过,签名算出来就跟服务端对不上,直接握手失败。
const timestamp = Math.floor(Date.now() / 1000); const signature = md5(apiKey + timestamp).toString(); const handshakeUrl = 'wss://rtasr.example.com/v1/ws' + '?appid=' + appId + '×tamp=' + timestamp + '&signature=' + signature;这段代码逻辑说明:先取秒级时间戳,再用接口密钥和时间戳拼接做一次 MD5 作为签名。URL 上带的三个参数是服务端用来校验请求合法性的,appid 标识调用方,timestamp 用于防止重放,signature 证明请求确实来自持有密钥的人。
参数说明:时间戳必须用秒级而不是毫秒级,很多人直接在Date.now()后面不除 1000,导致服务端解析出来的时间跟本地差 1000 倍;密钥拼接顺序要跟官网文档完全一致,有些接口是先拼接 appid 再拼时间戳再加密钥,拼错一个字符就 401。建议在本地写一个对照服务端时间戳的打印,确认偏差在 5 秒以内再往下走。
2.3 音频格式对齐:16k PCM 与小程序录音器输出
语音识别服务端对音频格式的要求通常是采样率 16000Hz、单声道、PCM 裸流。微信小程序的RecorderManager在 iOS 和 Android 上输出的默认格式不是 PCM,而是 MP3 或 AAC 编码的音频文件。如果直接把录好的 MP3 文件丢给识别接口,大概率会被拒绝,因为服务端默认解析的是裸流,MP3 有文件头有压缩帧,字节流直接塞进去解析出来全是噪声。
recorderManager.start({ duration: 600000, sampleRate: 16000, numberOfChannels: 1, encodeBitRate: 48000, format: 'mp3' });这段代码的意图是让录音器按 16k 采样、单声道录音。但 format 只能选 'mp3' 或 'aac',这是小程序的能力边界,不能在端上直接输出 PCM。常见做法是让录音器输出音频文件后用 AudioContext 解码成 PCM 再推流,或者依赖后段服务在接收端转码。
参数说明:sampleRate 设成 16000 是必须的,如果设成 44100,解码后的数据量翻近三倍,帧缓冲要重新算;numberOfChannels 必须设 1,双声道会让识别准确率明显下降。小程序真机录音的底层采样率可能被硬件重采样,缓冲帧大小按 16k 的 320 字节算,实际拿到 8k 数据时会发现帧时长翻倍,这是排查录音中断时最先要看的地方。
3. 在小程序端拉起录音并持续喂流
3.1 权限申请与 RecorderManager 初始化
小程序里录音不是拿到麦克风就能用的,要先在app.json里声明录音权限,而且在 iOS 真机上首次调用会弹授权框,用户选拒绝之后再次调用需要跳回设置页。这个流程的坑在于,开发者工具里调试时权限弹窗正常,但真机首次授权时如果用户点了“暂不”,第二次调用recorderManager.start()会直接失败,而且不抛任何错误回调。
"permission": { "scope.record": { "desc": "需要使用麦克风进行语音输入" } }这段配置写在 app.json 的 permission 字段,作用是声明录音目的,描述文字会展示在授权弹窗里。注意这个配置只能影响审核文案,不能替代代码里的wx.authorize调用。
在代码里我习惯先做一次权限探测再初始化录音器:
wx.getSetting({ success: (res) => { if (!res.authSetting['scope.record']) { wx.authorize({ scope: 'scope.record' }); } } }); const recorderManager = wx.getRecorderManager(); recorderManager.onError((err) => console.error('recorder error', err));这段逻辑说明:先查设置确认录音权限是否已授权,如果没授权就主动调一次授权。onError 回调里必须打印错误信息,iOS 上很多权限问题是通过 errmsg 才能看到真实原因。
3.2 帧缓冲与切帧发送策略
录音模块起来之后,直接拿到的是完整音频文件,不是实时流。为了让识别接口拿到实时数据,常见做法是循环读取录音文件的分片。你可以在onStop回调里拿到临时文件路径,然后通过wx.getFileSystemManager().readFile读取字节,再按帧大小切片发送。
const fs = wx.getFileSystemManager(); recorderManager.onStop((res) => { fs.readFile({ filePath: res.tempFilePath, success: (fileRes) => { const audioBuffer = fileRes.data; const frameSize = 320; let offset = 0; while (offset < audioBuffer.byteLength) { const frame = audioBuffer.slice(offset, offset + frameSize); sendFrame(frame); offset += frameSize; } } }); });这段代码的意图很直接:录音停止后读取整段文件,按 320 字节切成帧,每帧调一次 sendFrame 发送。但这里有个明显问题,整段发送不是实时,用户必须等录音结束才能看到文字。想要边说边出字,就得把录音时间和发送时间重叠起来。
我的方案是定时轮询读取已写入的临时文件:每 500ms 检查录音文件是否增长,把新增的字节读出来切帧发送。这个做法在开发者工具上可行,在真机上受限于文件系统刷新时机,偶尔会漏数据,之后在避坑章细说。
3.3 生命周期中断处理:退后台、来电、录音超时
小程序录音最烦的问题是生命周期:用户录着录着切换 App、来电铃声响起、iOS 锁屏,录音都会被系统打断,而 RecorderManager 可能既不触发 onStop 也不触发 onError,静静躺在那里。这种情况如果不处理,前端会一直以为在“录音中”,后续发送的全是空帧。
wx.onAppHide(() => { if (isRecording) { recorderManager.stop(); } }); recorderManager.onStop((res) => { isRecording = false; if (wsConnected) { sendEndSignal(); } });这段代码处理两件事:App 退到后台时主动停掉录音,onStop 时标记状态并通知服务端结束识别。需要注意的是,recorderManager.stop()会自动触发 onStop,但 onStop 里可能拿不到有效音频数据,因为系统已经在退场前杀掉了采集线程。安全做法是把临时路径清空、重置状态,而不是继续读文件。
还有一个容易忽略的参数是 duration。录音器默认 60 秒会自动停,长语音输入要主动设成 600 秒甚至更长。真机上超过 5 分钟持续录音时,内存占用会持续上涨,建议每录完一句就主动 stop 一次,让录音器释放资源再重新 start。
4. 从录音帧到文字结果:建立连接与消息解析
4.1 建立 WebSocket 连接并带上鉴权参数
音频帧准备好之后,下一步是把 WebSocket 连起来。小程序使用wx.connectSocket,注意要设置tcpNoDelay: true,否则音频帧会被 Nagle 算法缓冲,延迟能到 1 秒以上。连接成功后立刻发送鉴权参数,再开始推音频流。
const ws = wx.connectSocket({ url: handshakeUrl, tcpNoDelay: true }); ws.onOpen(() => { ws.send({ data: JSON.stringify({ appid: appId, timestamp: timestamp, signature: signature }) }); });这段代码说明连接成功后的第一步是发送 JSON 鉴权信息,等服务端返回确认消息再开始发音频。常见错误是打开连接后立刻发音频帧,而服务端还没做完身份校验,导致所有帧被丢弃或连接被重置。
参数说明:tcpNoDelay在小程序 Android 端默认是关闭的,不设置的话 40ms 级的帧会攒到一起发出去,延迟会非常难排查。真正线上业务还要在onClose里做重连,避免网络切换导致连接静默断开。
4.2 中间结果与最终结果解析
服务端返回的文本结果一般分两类:中间结果和最终结果。中间结果在说话过程中不断刷新,用户看到的效果是字幕在“跳字”,比如用户说“今天天气不错”,中间结果会先显示“今天”“今天天”“今天天 气”,最终结果则稳定输出整句。UI 上必须区分这两类,否则把中间结果直接提交业务逻辑,会出现字被覆盖、文案反复闪的问题。
ws.onMessage((res) => { const data = JSON.parse(res.data); if (data.code === 0) { if (data.type === 'final') { commitResult(data.text); } else { updatePendingText(data.text); } } });这段逻辑说明:每条下行消息都带有 code 字段标识是否成功,type 字段区分结果类型。镇定的做法是把最终结果提交到业务层,中间结果只做界面预览。
参数说明:服务端返回的 text 字段可能包含标点和空格变体,在不同参数下行为不一样。如果接口参数里没开标点预测,返回文本就是纯裸字,句子之间没有停顿符。建议在测试阶段把原始 JSON 完整打出来存日志,别只取 text,因为错误信息在desc字段里,只打印 code 会让排查走弯路。
4.3 结束发送与清理时序
录音结束不能直接关 WebSocket 连接,要先发一个结束信号让服务端把缓存帧识别完,再等服务端返回最终结果或关闭消息。很多新手直接把 socket 关掉,导致最后一句话丢了半个字,还会在服务端留下残帧报错。
function sendEndSignal() { ws.send({ data: JSON.stringify({ status: 2, text: '' }) }); }这段代码是发结束指令的通用形态,不同服务商字段名可能不同,有的是status,有的是end。关键是这个信号必须在所有音频帧发送完成之后再发,而且要等服务端的确认回执。
清理时序我推荐这样:端上状态标记为“等待最终结果”,收到最终结果后 500ms 内如果没有新消息到达,主动关闭连接并销毁录音器。如果 5 秒还没收到最终结果,按错误处理重新走一遍链路,而不是无限制等下去。
5. 小程序语音识别常见问题:五个高频翻车点与排查
5.1 现象:服务端返回 4002 或音频格式错误,一秒拒收
原因:权限和签名都没问题但接口仍拒绝,多半是格式参数跟实际音频数据不一致。最典型的是sampleRate设了 16000,但录音器底层输出 8k 采样,或者numberOfChannels设了 2,而服务端只支持单声道。
解决:先录一段固定时长的音频存到本地,用工具看实际采样率和声道数,再回头改录音参数。代码里只指导format: 'mp3'是文件封装格式,实际编码参数是采样率和码率,这两个值要对齐服务端要求。测试时把识别接口的原始错误码和 desc 打出来对比官方错误码表,比盲改参数高效得多。
5.2 现象:鉴权签名偶尔失败,时好时坏
原因:签名里用了本地时间,手机时间偏差超过服务端允许窗口(一般是 300 秒)。用户手动改了系统时间或者手机时区设置不对,签名就会偶发失效。
解决:不信任本地时间,请求签名前先调一个时间同步接口拿服务器时间,或者允许客户端上报时间偏移量让服务端校验时宽容处理。我在生产项目里直接改为每次连接前请求wx.cloud.callFunction拿云端时间戳,彻底绕开本地时间不可靠的问题。
5.3 现象:开发者工具里录音权限弹窗没出现,录音瞬间失败
原因:小程序的录音功能依赖真机硬件,微信开发者工具在电脑上用的是虚拟录音设备。工具里能打开录音界面但不代表真正有数据流,权限弹窗在部分 Windows 版本上不触发是已知行为。
解决:不要浪费时间去调工具,直接点开发者工具顶部的“真机调试”,用手机扫码跑完整链路。另外工具默认打开了“不校验合法域名”,这会掩盖 WebSocket 域名未配置的问题,等真机上跑通后记得关掉这个开关再验证一遍。
5.4 现象:长录音中途识别断开,前面正常后面无结果
原因:长时间保持录音和 WebSocket 连接时,小程序的onSocketMessage回调在高频帧下会触发内存告警,服务端也可能因为长时间没收到结束帧主动超时断开。
解决:两条路选一条——要么控制单次录音时长,录音到 60 秒自动停止并开启下一段;要么在每帧数据里带上序号,服务端能根据序号做断点续传。另外一个常规做法是每 10 秒向服务端发一个空帧检测保持连接活跃,别等超时了才后悔。
5.5 现象:识别结果丢字少词,短句子还行长句子漏掉中间词
原因:推送帧的节奏不均匀,帧间隔过大时服务端语音分割算法会认为句子已经结束,导致中间词被截断。还有一种情况是音频文件读取时机不对,文件还没写完整就开始切片,拷出来的字节本身是残缺的。
解决:客户端保证帧发送间隔不超过 100ms,用定时器匀速发送而不是while循环一次性发完。真机上读文件的频率要小于文件写入频率,建议每 100ms 读一次新增数据而不是 500ms。以我的经验,丢字问题 80% 出在切帧节奏不对,剩下 20% 出在音频编码参数没对齐。
6. 把识别结果接进业务流:UI 策略与验证方法
6.1 中间结果、最终结果的 UI 呈现差异
中间结果要做成灰字或半透明文字浮在输入框里,用户说话时看到字幕在增加但不落定。最终结果到达后用一句话替换整段中间结果,并作为正式内容提交到业务逻辑。如果中间结果和最终结果混在一个状态变量里,会出现文字跳动、输入框高度反复变化的问题。
6.2 断网重连与错误码映射
WebSocket 的onClose不能只打日志,要按错误码分策略。断网重连时不要重新弹授权框,也不要重走鉴权,而是用原有参数重建连接并补发最后一帧序号。错误码 4 位以上的一律查服务端错误码表,不要自己猜。前端只处理 2 类:连接层错误按重连处理,业务层错误按提示文案处理。
6.3 验收清单:从真机联调到上线前的自测
按这套流程自测过一轮,上线前基本能放心:一是 iPhone 和 Android 各找一台真机测长录音 3 分钟以上,确认中途不出现断流;二是开飞行模式 5 秒再恢复,确认重连后能续传;三是手动把手机时间改成未来一小时,确认鉴权失败后有时间校准提示;四是连着蓝牙耳机测一次,确认录音走的是耳机麦克风而不是机身麦克风。这套逻辑跑通之后,我养成的习惯是每次改完代码先测“丢字”再测“延迟”,丢字问题会直接影响用户对语音识别能力的信任,延迟只要控制在 300ms 内就感知不强。希望这篇笔记能帮你少走几趟弯路,后面真机调出问题的时候,记得先从帧格式和时间戳这两个最基础的变量查起。
本文还有配套的精品资源,点击获取