微信小程序里做音频录制不算新鲜事,但要把录制、试听、上传三个环节串成一条顺畅的链路,还要在录制过程中实时画出波形图,这就不是随便调两个API能糊弄过去的了。我最近刚在一个互动社区类的小程序项目里落地了这套方案,踩了不少坑,也攒了一些在官方文档里翻不到的经验。这篇文章就把整个实现思路、关键参数、波形绘制的取舍、上传环节的避坑点全部摊开讲一遍。
如果你正在做语音留言、在线测评、口语练习、K歌打分、会议记录这类需要"说话"的小程序,这套方案可以直接抄作业。哪怕你之前没碰过小程序的录音接口,跟着走一遍也能跑通。我会尽量把每个"为什么这么选"讲清楚,而不是只丢一段代码让你自己悟。
1. 整体方案设计与技术选型思路
1.1 为什么是"录制-试听-上传"三段式
先想清楚业务链路。用户按下按钮说话,说完要能回放确认,确认没问题再提交到服务器。这三步看似简单,但每一步都有独立的坑:录制涉及权限和格式,试听涉及播放器生命周期,上传涉及文件路径和网络。把它们拆成三个独立模块,各自管好自己的状态,比揉成一坨要稳得多。
我在项目里用的是状态机思路,用一个recordState变量控制整个流程:idle(空闲)、recording(录制中)、recorded(已录完待试听)、playing(试听中)、uploading(上传中)。任何操作前先判断当前状态,避免用户手快连点导致录音器被重复初始化。这个细节看起来不起眼,但实测能挡掉一大半的诡异bug。
1.2 录音API的选型:RecorderManager还是老接口
小程序录音有两套东西:老的wx.startRecord和新的wx.getRecorderManager()。前者已经不推荐用了,后者是全局单例,支持更多格式和参数。我毫不犹豫选RecorderManager,原因有三:一是它支持onFrameRecorded回调,这是画波形图的关键;二是它能拿到录制时长、文件大小等元信息;三是它支持暂停和继续,交互上更灵活。
注意:
RecorderManager是全局单例,整个小程序只有一个实例。如果你在多个页面都用录音,一定要在页面卸载时把监听器移除,否则会出现"在A页面录音,B页面也收到回调"的鬼故事。
1.3 波形图的技术选型:Canvas 2D还是WebGL
波形图这块我纠结过一阵。可选方案有三个:Canvas 2D、WebGL、以及用一堆div模拟。WebGL性能最好但杀鸡用牛刀,div方案在数据点多的时候直接卡死。最后选的是 Canvas 2D,配合小程序的Canvas 2D接口(注意不是老版的wx.createCanvasContext)。
新版 Canvas 2D 的优势在于它和Web标准基本一致,requestAnimationFrame能用,绘制性能也比老接口好。实测在千元安卓机上,每秒60帧绘制200个数据点毫无压力。这里的关键是:不要每帧重绘整个画布,而是用"滚动擦除"的方式,只擦掉最左边一小条,把新数据画在右边,这样性能能再上一个台阶。
1.4 音频格式的选择:mp3还是aac
RecorderManager支持mp3、aac、wav、PCM等格式。我的建议是:如果只是语音留言,用 aac 或 mp3;如果需要做音频分析(比如音高检测),用 PCM。
原因很直接:aac和mp3是有损压缩,文件小,上传快,适合网络传输;PCM是原始数据,体积大但保留了完整波形信息,适合本地做算法处理。我这个项目是语音留言场景,选了mp3,采样率16000,码率48kbps,实测1分钟录音大约360KB,用户用4G网络上传也就一两秒的事。
| 格式 | 采样率建议 | 码率建议 | 1分钟体积 | 适用场景 |
|---|---|---|---|---|
| mp3 | 16000 | 48kbps | 约360KB | 语音留言、通用场景 |
| aac | 16000 | 48kbps | 约350KB | 对音质略敏感的语音 |
| wav | 16000 | - | 约1.8MB | 本地分析、短音频 |
| PCM | 16000 | - | 约1.8MB | 实时算法处理 |
2. 录音权限与初始化:别让用户第一次就卡住
2.1 权限申请的时机与话术
小程序录音需要scope.record权限。这里有个大坑:不要在页面一加载就申请权限,用户还没搞清楚你要干嘛,弹个授权框出来,大概率被拒。我的做法是:用户第一次点击"开始录音"按钮时再申请,并且在此之前用一个自定义的引导弹窗说明"我们需要麦克风权限来录制你的语音"。
申请权限的代码逻辑是这样的:先调wx.getSetting查当前授权状态,如果scope.record是undefined(从没问过),就调wx.authorize发起申请;如果是false(拒绝过),就引导用户去wx.openSetting手动打开。这个分支一定要写全,否则用户拒绝一次之后就再也录不了了。
// 权限检查与申请 async function checkRecordAuth() { const setting = await wx.getSetting(); if (setting.authSetting['scope.record'] === undefined) { // 首次申请 try { await wx.authorize({ scope: 'scope.record' }); return true; } catch (e) { return false; } } else if (setting.authSetting['scope.record'] === false) { // 曾被拒绝,引导去设置页 const res = await wx.showModal({ title: '需要麦克风权限', content: '请在设置中开启麦克风权限后重试', confirmText: '去设置' }); if (res.confirm) { const s = await wx.openSetting(); return !!s.authSetting['scope.record']; } return false; } return true; }2.2 RecorderManager的初始化与监听器管理
RecorderManager的监听器是全局的,一旦注册就会一直存在。我的做法是把它封装成一个类,在类的构造函数里注册监听器,在destroy方法里移除。这样每个页面用的时候 new 一个,用完 destroy,互不干扰。
需要注册的监听器有四个:onStart(开始录制)、onStop(录制结束,拿到临时文件路径)、onFrameRecorded(每帧数据,画波形用)、onError(出错)。其中onFrameRecorded是波形图的核心,它会在录制过程中按frameSize的大小不断回调,每次给你一段 ArrayBuffer。
实操心得:
frameSize默认是 1KB,我建议设成 4KB。太小了回调太频繁,主线程压力大;太大了波形刷新不够细腻。4KB在16000采样率、16位深度下大约是128ms的音频,画波形刚好。
2.3 录音参数的配置细节
start方法的参数配置直接决定音质和文件大小。我常用的配置如下:
const options = { duration: 60000, // 最长60秒,超时自动停止 sampleRate: 16000, // 采样率16k,语音够用 numberOfChannels: 1, // 单声道,省体积 encodeBitRate: 48000, // 码率48k format: 'mp3', // 格式 frameSize: 4 // 帧大小4KB,波形用 }; recorderManager.start(options);这里duration是毫秒,设60000就是最长录1分钟。注意这个超时是硬性的,到点自动停,onStop会正常回调。如果你需要"倒计时提醒",得自己用setInterval算时间,在剩余10秒时给用户提示。
3. 波形图的实时绘制:从数据到像素
3.1 波形数据的来源与处理
onFrameRecorded回调给的是ArrayBuffer,里面是PCM原始数据。要画波形,得先把它转成一组振幅值。我的做法是:把 ArrayBuffer 转成 Int16Array(因为16位深度),然后每N个点取一个最大值,形成一个"峰值数组"。
为什么要取峰值而不是平均值?因为波形图看的是振幅包络,峰值更能反映声音的强弱变化。如果取平均值,正负抵消后会变成一条直线,啥也看不出来。
function bufferToPeaks(arrayBuffer, pointsPerPeak = 8) { const int16 = new Int16Array(arrayBuffer); const peaks = []; for (let i = 0; i < int16.length; i += pointsPerPeak) { let max = 0; for (let j = 0; j < pointsPerPeak && i + j < int16.length; j++) { const v = Math.abs(int16[i + j]); if (v > max) max = v; } peaks.push(max / 32768); // 归一化到0-1 } return peaks; }pointsPerPeak这个参数控制波形密度。设8意味着每8个采样点出一个峰值,16000采样率下每秒出2000个峰值。但画布宽度可能只有300px,所以实际绘制时还要再做一次降采样,把2000个点映射到300px上。
3.2 Canvas 2D的初始化与适配
新版 Canvas 2D 的初始化有点绕,需要先拿到 canvas 节点,再拿 context。而且要考虑设备像素比(dpr),否则在高清屏上会糊。
const query = wx.createSelectorQuery(); query.select('#waveCanvas') .fields({ node: true, size: true }) .exec((res) => { const canvas = res[0].node; const ctx = canvas.getContext('2d'); const dpr = wx.getSystemInfoSync().pixelRatio; canvas.width = res[0].width * dpr; canvas.height = res[0].height * dpr; ctx.scale(dpr, dpr); // 保存起来供后续绘制 this.canvas = canvas; this.ctx = ctx; this.canvasWidth = res[0].width; this.canvasHeight = res[0].height; });注意:
canvas.width和 CSS 里的宽度是两码事。前者是画布的实际像素,后者是显示尺寸。乘以dpr之后,画布清晰度才对。这个坑我见过太多人踩,画出来的波形永远是模糊的。
3.3 滚动式波形绘制的实现
波形绘制有两种模式:整段重绘和滚动绘制。整段重绘是每次拿到新数据就把所有峰值重新画一遍,简单但性能差;滚动绘制是只画新增的部分,性能好但逻辑复杂。
我选的是滚动绘制。核心思路是:维护一个峰值数组peaks,每帧从数组尾部取最新的若干个点,画在画布右侧,同时把画布整体向左"推"。但Canvas没有"推"这个操作,实际做法是:每帧用clearRect擦掉最左边一小条,然后把新数据画在最右边。
function drawWave(peaks, offsetX) { const ctx = this.ctx; const w = this.canvasWidth; const h = this.canvasHeight; const mid = h / 2; // 擦掉最左边1px ctx.clearRect(0, 0, 1, h); // 整体左移1px(用drawImage实现) ctx.drawImage(this.canvas, 1, 0, w - 1, h, 0, 0, w - 1, h); // 在右侧画新的一列 const peak = peaks[peaks.length - 1] || 0; const barHeight = peak * mid * 0.9; ctx.fillStyle = '#07C160'; ctx.fillRect(w - 1, mid - barHeight, 1, barHeight * 2); }这个方案实测在低端机上也能跑满60帧。但有个细节:drawImage把画布画到自己身上,在某些安卓机型上会有性能问题。如果遇到卡顿,可以改用双缓冲:准备两个canvas,一个显示一个绘制,交替使用。
3.4 波形图的视觉优化
纯绿色的竖线太丑了,我做了几个视觉优化。一是加渐变,从中间向上下两端颜色变浅;二是加圆角,每根竖线用roundRect画成胶囊状;三是加一条中线,让视觉有基准。
还有一个细节:静音时的波形不应该完全消失,否则用户会以为录音坏了。我的做法是给振幅设一个最小值,比如0.02,这样即使没声音,波形也是一条细细的线,用户能看出"在录但没声"。
4. 试听播放:别让播放器变成脱缰野马
4.1 InnerAudioContext的生命周期管理
试听用的是wx.createInnerAudioContext()。这个对象也是需要手动管理的,用完要destroy(),否则会一直占着音频通道。我的做法是在页面onUnload里统一销毁,同时用一个audioCtx变量持有引用。
播放的流程是:设置src为录音返回的临时路径,调play(),监听onEnded把状态改回recorded。这里有个坑:临时文件路径在小程序重启后会失效,所以如果你要持久化,得用wx.getFileSystemManager().saveFile()存到本地用户目录。
4.2 播放进度的实时反馈
用户试听时想知道"播到哪了",这就需要进度条。InnerAudioContext提供了currentTime和duration属性,但它们是只读的,需要自己用setInterval轮询。
this.progressTimer = setInterval(() => { const current = this.audioCtx.currentTime; const total = this.audioCtx.duration; this.setData({ progress: total > 0 ? current / total : 0, currentTime: formatTime(current) }); }, 200);200ms的轮询间隔是个平衡点:再短了没必要,再长了进度条会一跳一跳的。注意在onEnded和onStop里都要清掉这个定时器,否则会内存泄漏。
4.3 试听与重录的交互设计
试听界面我放了三个按钮:重录、播放/暂停、上传。重录会清空当前录音和波形,回到idle状态;播放/暂停切换试听;上传则进入上传流程。
这里有个交互细节:播放中点击重录,要先停止播放再清空,否则会出现"录音已经删了但声音还在响"的尴尬。我的做法是在重录的处理函数里先调audioCtx.stop(),再清状态。
5. 音频上传:临时路径到服务器的最后一公里
5.1 上传接口的选择:uploadFile还是云存储
小程序上传文件有两条路:wx.uploadFile传到自己的服务器,或者用云开发的wx.cloud.uploadFile传到云存储。我选的是前者,因为项目后端是自建的,而且uploadFile更灵活,能自定义header和formData。
uploadFile的关键参数是filePath(录音返回的临时路径)、name(后端接收的字段名)、formData(附加参数,比如用户ID、录音时长)。注意filePath必须是本地路径,不能是网络URL。
wx.uploadFile({ url: 'https://your-server.com/api/upload', filePath: this.tempFilePath, name: 'audio', formData: { userId: this.data.userId, duration: this.recordDuration }, success: (res) => { const data = JSON.parse(res.data); // 处理返回结果 }, fail: (err) => { // 错误处理 } });5.2 上传进度的监听与展示
uploadFile返回的是一个UploadTask对象,可以监听onProgressUpdate拿到上传进度。这个进度是0-100的百分比,直接绑到进度条上就行。
const task = wx.uploadFile({...}); task.onProgressUpdate((res) => { this.setData({ uploadProgress: res.progress }); });实操心得:上传进度到100%不代表服务器已经处理完,只代表数据发出去了。真正的"上传成功"要以
success回调为准。我见过有项目在进度100%时就提示"上传成功",结果服务器返回失败,用户体验很糟。
5.3 上传失败的重试与降级
网络请求失败是常态,尤其是移动网络。我的做法是:失败后弹一个"上传失败,是否重试"的弹窗,用户点重试就重新调上传。同时记录重试次数,超过3次就提示"网络异常,请稍后再试"。
还有一个降级策略:如果上传大文件一直失败,可以尝试降低码率重新编码。但小程序端重新编码音频比较麻烦,我的做法是在录制时就控制好体积,从源头避免大文件。
6. 常见问题与排查技巧实录
6.1 录音相关的高频问题
| 问题现象 | 可能原因 | 排查与解决 |
|---|---|---|
| 点击录音没反应 | 权限未授予 | 检查scope.record,引导去设置页 |
| 录音时长总是0 | onStop没拿到duration | 用onStart和onStop的时间差自己算 |
| 波形图不刷新 | onFrameRecorded没触发 | 检查frameSize是否设置,格式是否支持 |
| 录音文件播放没声音 | 采样率不匹配 | 播放器采样率和录音采样率保持一致 |
| 安卓机录音有杂音 | 硬件差异 | 降低码率,或改用aac格式 |
6.2 波形图绘制的性能问题
波形图最容易出的问题是卡顿。我总结了几条优化经验:一是降低绘制频率,不要每帧都画,可以每两帧画一次;二是减少数据点,把峰值数组控制在画布宽度的2倍以内;三是避免频繁创建对象,把ctx、canvas这些缓存起来,不要在绘制函数里重新获取。
还有一个隐蔽的坑:requestAnimationFrame在小程序里是绑定在canvas节点上的,不是全局的。你得用canvas.requestAnimationFrame(),而不是wx.requestAnimationFrame()。这个区别官方文档写得比较隐晦,我第一次用的时候找了半天。
6.3 上传环节的踩坑记录
上传最大的坑是临时文件路径失效。录音返回的tempFilePath在小程序本次生命周期内有效,但如果用户录完切到后台,过一会儿再回来,这个路径可能就失效了。我的做法是录完立即上传,或者用saveFile存到持久化目录。
另一个坑是并发上传。如果用户快速录了多段音频,同时触发多个上传,可能会出现文件覆盖或服务器压力过大。我的做法是加一个上传队列,一次只传一个,传完再传下一个。
6.4 跨平台兼容性注意事项
小程序在iOS和安卓上的表现有差异。iOS的录音权限弹窗只弹一次,之后在设置里改;安卓则可能每次都要检查。波形绘制上,iOS的Canvas性能普遍好于安卓,所以参数要按最低标准来设。
还有一点:iOS上录音会被系统电话打断。如果用户录音时来了电话,录音会中断,onStop会触发但文件可能不完整。我的做法是监听wx.onAudioInterruptionBegin和wx.onAudioInterruptionEnd,在打断时给用户提示。
7. 完整实现流程与关键代码串联
7.1 页面结构与状态定义
整个页面就三块:波形画布、控制按钮、进度显示。状态用一个对象管理:
data: { recordState: 'idle', // idle | recording | recorded | playing | uploading recordDuration: 0, uploadProgress: 0, tempFilePath: '', peaks: [] }7.2 录制流程的完整串联
从点击"开始录音"到"录制完成",完整流程是:检查权限 → 初始化RecorderManager → 启动录制 → 监听帧数据画波形 → 监听停止拿到文件路径 → 更新状态为recorded。每一步都要处理异常,比如权限被拒、录制出错。
7.3 试听与上传的衔接
录制完成后,用户点"播放"进入试听,点"上传"进入上传。上传成功后清空状态,回到idle;上传失败保留状态,允许重试。这个衔接的关键是状态不能乱,任何操作前先判断当前状态是否允许。
8. 一些个人经验与后续扩展方向
这套方案我在两个项目里用过,整体稳定。如果让我重新做,我会在波形图上加一个"音量指示",用颜色深浅反映当前音量大小,用户能更直观地知道"声音够不够大"。
后续扩展的话,可以考虑几个方向:一是加变声功能,用WebAudio的playbackRate做简单的变速变调;二是加语音转文字,把录音上传后调第三方接口;三是加波形回放,试听时波形跟着进度走,视觉反馈更强。
最后分享一个小技巧:调试录音功能时,真机调试是必须的,模拟器的录音是假的,波形数据也是模拟的,很多问题只有在真机上才能复现。我习惯准备两台设备,一台iOS一台安卓,每次改完代码都双端跑一遍,能省掉很多"用户反馈的诡异bug"。