
字节笔记本
2026年10月7日 · 约 9 分钟读完
微信小程序录音波形图实战:PCM 解析与 Canvas 绘制踩坑
先从需求说起。语音消息、语音输入、录音笔记……凡是出现录音按钮的地方,旁边几乎总有一条跳动的波形:它告诉用户“正在录”,也用起伏的幅度暗示“你的声音正被听见”。在浏览器里这是现成能力,Web Audio API 的 AnalyserNode 接上音频流就能拿到实时频谱;但小程序的逻辑层没有 window、没有 DOM,也没有 Web Audio,这条路走不通。唯一可行的路线,是让录音管理器把原始 PCM 帧交出来,自己解析振幅,再用 Canvas 2D 逐帧画出来。这条链路不长,但每一段都埋着只有真机跑起来才会暴露的坑。
三段式管道

整条链路可以概括为三段:
- 采集:
wx.getRecorderManager()拿到全局唯一的 RecorderManager 实例,start()时指定format: 'pcm'和frameSize,录音数据才会按帧回调; - 解析:在
onFrameRecorded回调里拿到frameBuffer,它是 16 位有符号小端 PCM,用DataView.getInt16(i * 2, true)逐样本取绝对值,得到振幅序列; - 绘制:把振幅映射成 canvas 上的纵坐标,用折线或曲线连起来,每帧
clearRect后重画。
先解释一个名词:PCM 是最原始的音频数字化格式,声音按固定采样率被记录成一串带符号的整数,波形之所以起伏,正是这些整数在正负之间来回摆动。小程序录音若指定 mp3 等编码格式,拿到的是压缩后的文件,取不到逐帧样本;要画实时波形,就得主动要 PCM。
再展开 frameSize 的语义:它约定“每录制多少 KB 就回调一帧”,且帧回调目前仅支持 mp3 与 pcm 两种格式,画实时波形自然选后者。官方文档写得很清楚,onFrameRecorded 只在显式指定了 frameSize 之后才会触发,不指定则完全不会回调。对照两版实现能看到差别——原生版补上了 frameSize: 1,而最初那版 UniApp 代码恰恰漏掉了它,结果就是录音正常、波形一动不动,控制台还不报错。这种“静默失效”最耗排查时间,接入任何回调式 API 前,先确认它的触发前置条件。
再算一笔数据量的账:44100Hz 采样率、16 位单声道,一秒原始 PCM 约 86KB,一帧回调携带的采样点远多于 canvas 的像素数,不可能逐点画上屏。合理的做法是先进缓冲区,再按 canvas 列宽分桶,每桶取最大值或平均值后绘制。示例代码里还藏着一个隐蔽 bug:getInt16 绝对值的范围是 0 到 32767,却用 Uint8Array 承接,赋值时按 256 取模,波形会出现诡异的周期性抖动。要么除以 256 归一化,要么直接采用按列取最大值的分桶法,顺便把降采样一起解决。
UniApp 还是原生
UniApp 版用 uni.getRecorderManager() 和 uni.createCanvasContext(),好处是同一套代码能编译到多端;但旧版 canvas 接口能力有限,上下文上连宽高都读不到,高频率重绘也容易卡。原生版有两个直接优势:一是 API 直达,不用等跨端封装跟进——小程序的新特性历来是原生文档先行,跨端框架的支持慢半拍;二是可以用新的 <canvas type="2d"> 接口,通过 SelectorQuery 的 fields({ node: true, size: true }) 拿到 canvas 节点,getContext('2d') 之后画法与浏览器基本一致,还能顺手做 DPR 适配——把 canvas.width 乘以 pixelRatio 再 ctx.scale(dpr, dpr),高分屏上线条才不会发虚。当然 UniApp 并非没有价值,同一录音页要同时发 App、H5 和其他小程序端时,抽象层的意义就显现了;只是音频可视化这类平台绑定深的场景,抽象层帮不上太多忙。项目本就只跑微信端的话,直接上原生更省心。
高频状态别放进 data
采集和绘制之间需要一个缓冲区,持有最近一段振幅数据,让波形呈现“最近几秒”的滚动效果。这里的教训比技巧更重要:高频更新的大数组不要放进 Page 的 data。data 的每次变更都要 setData 同步到渲染层,本质是一次序列化加通信;录音回调每秒触发多次,数组动辄上万元素,放进去就是把渲染通道堵死。正确做法是把它挂在 this 上当普通实例属性,startRecording 时重置,每次追加后用 slice(-bufferSize) 截断成固定长度的环形缓冲。
结构上还可以再进一步:采集与绘制解耦。录音回调只负责往缓冲区写数据,绘制按自己的节奏跑,至多每个刷新周期重画一次,回调频率再怎么抖动,帧率也是稳的。
踩坑一:undefined is not iterable
第一版缓冲管理用了扩展运算符:
this.audioBuffer = [...this.audioBuffer, ...dataArray]真机上报 TypeError: undefined is not iterable (cannot read property Symbol(Symbol.iterator))。根因是 audioBuffer 一会儿从 this.data 读、一会儿往 this 上写,路径不一致时读到的就是 undefined,而展开运算符遇到 undefined 会直接抛错。修复分两步:初始化收敛到一处,在 startRecording 里显式重置;追加改用不要求可迭代的写法 this.audioBuffer.concat(Array.from(dataArray))。顺手把缓冲区挪出 data 之后,这个报错和上一节的性能隐患一起解决了。教训可以泛化:用 ... 展开一个值之前,先确认它必然存在且可迭代。
踩坑二:requestAnimationFrame is not a function

按浏览器习惯写绘制循环,马上撞上第二个报错:requestAnimationFrame is not a function。小程序逻辑层没有浏览器全局对象,window 都不存在,rAF 自然无从谈起。
最直接的兜底是用 setTimeout(draw, 1000 / 60) 手动循环,能用,但节流精度差,页面切后台还得自己管暂停。更好的修法藏在新版 canvas 里:type="2d" 拿到的 canvas 节点本身就实现了 requestAnimationFrame,这是官方为补齐浏览器能力特意加在节点上的方法。用它驱动绘制循环,帧率跟随系统刷新,页面隐藏时自动暂停,两全其美。同一个思路还能排掉第三处浏览器直觉:canvas 节点上并没有 getBoundingClientRect,拿画布尺寸、做触摸定位都得走 SelectorQuery 的 boundingClientRect,别照抄 DOM 的写法。
打磨与延伸
基础波形跑通之后,视觉和交互的加分项都很便宜:用 quadraticCurveTo 取相邻两点中点做二次贝塞尔插值,锯齿立刻变顺滑;ctx.scale(1, -1) 配合平移把同一帧再描一次,就得到上下镜像的专业观感;createLinearGradient 从绿到黄的渐变,还能顺带充当音量指示。播放侧,InnerAudioContext 直接播录音文件,触摸波形按横坐标换算成 seek 位置即可——录音存文件、波形只作显示,是比“解析 PCM 再手工编码”现实得多的组合。
性能上也有余量可抠:数据没有变化就跳过重绘;录音很长时按可视区间做最值摘要,而不是全量逐点绘制;真要做到超长录音的实时渲染,再考虑上 WebGL,多数场景到不了那一步。工程细节同样别漏:录音依赖 scope.record 授权,授权失败的分支要有兜底提示;页面 onUnload 时停掉绘制循环并销毁音频实例,来回切换页面才不会泄漏计时器。
回头看,这条链路没有一步涉及高深算法,难的是把浏览器直觉逐条翻译成小程序语义:没有全局 rAF 就找节点上的替代品,没有 DOM 就用 SelectorQuery,setData 只留给真正要上屏的状态。这套翻译思维,比任何一段可以直接复制的代码都更值得带走。



