☰
微信小程序音频录制、试听、上传与波形图全流程实战
2026/10/13 12:04:17 网站建设 项目流程

微信小程序里做音频录制不算难,官方给了RecorderManager,几行代码就能把声音录下来。但真正让开发者头疼的是三件事:录完之后怎么让用户马上试听、怎么把音频文件传到服务器、以及怎么画出一条能跟着播放进度走的波形图。我最近在一个语音打卡类的小程序里完整走了一遍这套流程,踩了不少坑,也攒了一些比较实用的经验。这篇文章就把录制、试听、上传、波形图这四块拆开讲清楚,从权限申请到文件格式选择,从波形数据采样到上传时的进度反馈,尽量把每个环节的"为什么"和"怎么做"都说明白。不管你是刚接触小程序音频能力的新手,还是已经用过RecorderManager但被波形图卡住的老手,应该都能从里面找到能直接抄作业的部分。

1. 先把录音这件事的边界摸清楚

很多人一上来就写start(),结果真机上一跑发现录出来是空的,或者时长不对,或者用户第一次点的时候弹不出授权框。这些问题其实都源于对小程序录音能力边界的不了解。在动手之前,有必要先把几个关键约束搞清楚。

1.1 RecorderManager 能做什么、不能做什么

小程序的录音能力由wx.getRecorderManager()返回的全局单例对象提供。它支持开始、暂停、继续、停止、监听录制过程等操作,输出格式支持aac和mp3两种主流格式,也支持PCM这种原始数据格式。采样率可以选 8000、16000、44100 等档位,码率也能调。

但它有几个明显的限制。第一,它同一时间只能有一个录制实例在工作,你不能同时开两路录音。第二,录制过程中小程序如果被切到后台,不同平台的表现不一致,有的会继续录,有的会暂停,所以正式项目里最好在onHide里主动处理。第三,它不提供实时的音频振幅数据回调,也就是说波形图不能直接从录音过程里拿数据,这一点是后面波形图方案设计的核心前提,很多人一开始就卡在这里。

提示:RecorderManager是全局单例,多次调用getRecorderManager()拿到的是同一个对象。如果你在多个页面里都注册了onStop回调,会出现回调重复触发的问题,建议在页面卸载时用offStop之类的接口把监听摘掉。

1.2 录音授权与用户拒绝后的处理

录音属于敏感权限,第一次调用start()时系统会弹窗询问。这里有个容易被忽略的点:如果用户第一次拒绝了,后续再调用start()往往不会再次弹窗,而是直接走fail回调。所以正确的做法是在录制前先用wx.getSetting查一下scope.record的状态。

wx.getSetting({ success(res) { if (!res.authSetting['scope.record']) { // 未授权,主动发起授权 wx.authorize({ scope: 'scope.record', success() { /* 可以开始录制 */ }, fail() { // 用户拒绝过,引导去设置页 wx.showModal({ title: '需要录音权限', content: '请在设置中开启录音权限后重试', success(r) { if (r.confirm) wx.openSetting() } }) } }) } } })

这段逻辑看起来简单,但实际项目里我见过太多人直接调start(),结果用户拒绝一次之后整个功能就"哑"了,没有任何提示,体验非常差。把授权判断前置,是录音功能稳定性的第一道保障。

1.3 格式与参数的选择逻辑

格式选择不是随便挑一个就行。aac压缩率高、体积小,适合网络传输;mp3兼容性最好,几乎什么播放器都能放;PCM是无压缩的原始数据,体积大但方便做音频分析。如果你的场景只是录一段语音上传,选aac或mp3就够了。如果你后面要做音频可视化分析、语音识别预处理,那PCM更合适,因为它是未经编码的采样点,能直接读。

采样率方面,人声场景 16000Hz 已经足够清晰,44100Hz 是音乐级标准但文件会大不少。码率一般 48kbps 到 96kbps 对人声来说够用。我一般会这样配:

const options = { duration: 60000, // 最长录制 60 秒 sampleRate: 16000, // 人声够用 numberOfChannels: 1, // 单声道,减小体积 encodeBitRate: 48000, // 码率 format: 'mp3' // 兼容性好 } recorderManager.start(options)

duration这个参数要特别注意,它设的是最长录制时长,到点会自动停止并触发onStop。如果你不设,默认是 60000 毫秒。很多人在做"按住说话"功能时忘了设这个,用户一直按着就一直录,最后文件大得离谱。

2. 录制、暂停与试听的完整闭环

录音功能如果只是"录完存下来",那太单薄了。用户真正需要的是录完能立刻听一遍,不满意就重录。这个闭环里藏着不少细节,尤其是暂停和继续的处理,以及试听时音频对象的生命周期管理。

2.1 录制状态机的设计

录制过程其实是一个状态机:空闲、录制中、已暂停、已停止。如果不把这个状态理清楚,按钮的文案和可用性就会乱。我一般用一个status字段来标记,配合按钮的禁用逻辑。

data: { status: 'idle', // idle | recording | paused | stopped tempFilePath: '', duration: 0 }

开始录制时把status设为recording,暂停设为paused,停止后设为stopped并保存临时文件路径。按钮的显示根据状态切换:空闲时显示"开始录音",录制中显示"暂停"和"停止",暂停时显示"继续"和"停止",停止后显示"试听"和"重录"。这样用户任何时候都知道当前处于什么阶段,不会出现"点了没反应"的困惑。

2.2 暂停与继续的坑

recorderManager.pause()和resume()看起来是一对简单的接口,但实际用起来有几个坑。第一,暂停期间如果用户切到后台再回来,resume()可能失效,需要重新判断状态。第二,暂停和继续的调用不能太频繁,否则在某些机型上会丢帧。第三,暂停后onStop返回的时长是累计的有效录制时长,不包含暂停的时间,这个符合直觉,但如果你自己用计时器算时长,就要把暂停时间扣掉。

我自己的做法是:用一个startTime记录开始时刻,暂停时记录pauseStart,继续时把暂停时长累加到totalPause,最后用Date.now() - startTime - totalPause来估算实际录制时长。这样即使onStop回调有延迟,界面上的计时也是准的。

2.3 试听功能的实现与资源释放

试听用的是wx.createInnerAudioContext()。这里最大的坑是音频对象的复用和销毁。如果你每次试听都create一个新的,很快就会出现多个音频同时播放、或者旧音频还在响的情况。

// 全局只创建一个 this.audioCtx = wx.createInnerAudioContext() this.audioCtx.onEnded(() => { this.setData({ playing: false }) }) this.audioCtx.onError((err) => { console.error('播放失败', err) this.setData({ playing: false }) }) // 试听 playAudio() { this.audioCtx.src = this.data.tempFilePath this.audioCtx.play() this.setData({ playing: true }) } // 页面卸载时销毁 onUnload() { if (this.audioCtx) { this.audioCtx.destroy() this.audioCtx = null } }

onEnded和onError一定要监听,否则播放结束后按钮状态不会复位,用户会以为卡住了。另外destroy()必须调用,不然音频对象会一直占着内存,页面来回切换几次就可能出问题。

注意:InnerAudioContext的src支持本地临时文件路径,也支持网络地址。试听本地录音时直接用tempFilePath即可,不需要先上传再播放,这样响应更快,也省流量。

3. 上传音频:从临时文件到服务器

录完的音频存在临时路径里,小程序关闭后这个文件就没了,所以必须上传到自己的服务器保存。上传本身用wx.uploadFile就行,但里面有不少细节决定了用户体验的好坏。

3.1 临时文件的有效期与上传时机

tempFilePath对应的临时文件在小程序本次运行期间有效,一旦小程序被销毁,文件就可能被清理。所以上传时机最好是在用户确认"就用这段录音"之后立刻进行,不要拖到用户离开页面才传。如果用户录完就退出,这段录音就丢了。

我的做法是在用户点击"提交"时先上传,上传成功后再跳转或提示。如果上传失败,保留临时文件让用户可以重试,而不是直接清空。

3.2 uploadFile 的参数与进度反馈

const uploadTask = wx.uploadFile({ url: 'https://your-domain.com/api/upload', filePath: this.data.tempFilePath, name: 'audio', formData: { duration: this.data.duration, format: 'mp3' }, success(res) { const data = JSON.parse(res.data) // 处理返回的文件地址 }, fail(err) { // 上传失败处理 } }) uploadTask.onProgressUpdate((res) => { this.setData({ progress: res.progress }) })

name这个字段必须和后端接收的字段名一致,否则后端拿不到文件。formData可以带一些附加信息,比如时长、格式、业务 ID。onProgressUpdate能拿到上传进度,对于几十秒的音频文件,加一个进度条会明显提升体验,用户知道系统在干活,不会以为卡死了。

3.3 上传失败的重试与断点思路

网络不稳定时上传失败很常见。简单的做法是失败后弹窗让用户重试。稍微好一点的做法是记录失败次数,自动重试两到三次,每次间隔递增。如果音频文件比较大,还可以考虑分片上传,把文件切成若干块分别上传,最后在服务端合并。不过对于几十秒的语音,分片有点杀鸡用牛刀,普通上传加自动重试就够了。

这里有个经验:上传前先检查文件大小,如果超过服务端限制(比如 10MB),提前提示用户重新录制,而不是等传了一半才失败。wx.getFileInfo可以拿到临时文件的大小。

wx.getFileInfo({ filePath: this.data.tempFilePath, success(res) { if (res.size > 10 * 1024 * 1024) { wx.showToast({ title: '录音过大,请重录', icon: 'none' }) } } })

4. 波形图:没有实时振幅数据怎么办

这是整个项目里最有技术含量的部分。前面说过,RecorderManager不提供实时振幅回调,所以你不能像某些原生 App 那样边录边画波形。那波形图怎么来?有两条路:一是录制时用PCM格式,录完后解析采样点画静态波形;二是录制时用onFrameRecorded回调拿分帧数据,实时计算振幅。我两种都试过,下面分别说。

4.1 用 onFrameRecorded 拿分帧数据

RecorderManager有一个onFrameRecorded回调,当frameSize设置后,录制过程中会按帧返回数据。这个回调返回的是ArrayBuffer,里面是 PCM 采样数据。你可以对它做处理,算出每一帧的音量大小,然后驱动波形绘制。

recorderManager.start({ format: 'PCM', frameSize: 4, // 单位 KB,每帧大小 sampleRate: 16000, numberOfChannels: 1 }) recorderManager.onFrameRecorded((res) => { const { frameBuffer, isLastFrame } = res const amplitude = this.calcAmplitude(frameBuffer) this.waveData.push(amplitude) this.drawWave() })

calcAmplitude的思路是把ArrayBuffer转成Int16Array(PCM 16 位采样),然后取这一帧里采样点的绝对值平均或峰值。

calcAmplitude(buffer) { const view = new Int16Array(buffer) let sum = 0 for (let i = 0; i < view.length; i++) { sum += Math.abs(view[i]) } return sum / view.length }

这样每来一帧就得到一个振幅值,push 进数组,然后重绘波形。frameSize越小,波形越细腻,但回调频率越高,性能压力越大。我一般设 4KB 左右,兼顾细腻度和性能。

注意:用PCM格式录制时,onFrameRecorded才会稳定触发。如果你用mp3或aac,这个回调拿到的可能是编码后的数据,不适合直接算振幅。所以想要实时波形,格式必须选PCM。

4.2 用 Canvas 绘制波形

小程序里画波形用Canvas 2D接口最顺手。先在 wxml 里放一个 canvas,然后在 js 里拿到上下文。

<canvas type="2d" id="waveCanvas" style="width: 100%; height: 120px;"></canvas>
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 })

绘制时,把振幅数组映射成一系列竖线,高度对应振幅大小,横坐标均匀分布。

drawWave() { const ctx = this.ctx const width = this.canvasWidth const height = this.canvasHeight ctx.clearRect(0, 0, width, height) const data = this.waveData const barWidth = width / data.length const maxAmp = Math.max(...data, 1) data.forEach((amp, i) => { const barHeight = (amp / maxAmp) * height * 0.8 const x = i * barWidth const y = (height - barHeight) / 2 ctx.fillStyle = '#4a90d9' ctx.fillRect(x, y, Math.max(barWidth - 1, 1), barHeight) }) }

这里用maxAmp做归一化,保证波形始终占满高度,不会因为音量小就缩成一条线。barWidth - 1是为了让柱子之间留一点缝隙,看起来更清晰。

4.3 播放进度与波形的联动

光有静态波形还不够,用户试听时希望看到进度条在波形上移动。这个实现思路是:在波形上叠一层半透明的遮罩,遮罩的宽度随播放进度变化。

this.audioCtx.onTimeUpdate(() => { const progress = this.audioCtx.currentTime / this.audioCtx.duration this.setData({ playProgress: progress }) })

然后在 canvas 上再画一个矩形,宽度是width * progress,颜色用半透明,覆盖在波形上。或者更简单一点,用两个 canvas 叠加,底层画波形,上层画进度遮罩。onTimeUpdate的触发频率大约是每秒几次,对于进度显示够用了。

如果要做"点击波形跳转到对应位置",那就需要把点击的 x 坐标换算成时间:time = (clickX / width) * duration,然后设置audioCtx.seek(time)。这个交互在语音消息类产品里很常见,做出来体验会好很多。

4.4 静态波形的降级方案

如果你的场景不需要实时波形,只是录完后展示一个静态波形,那可以更省事:录完后用PCM文件解析出所有采样点,按固定间隔抽样,算出每个区间的振幅,一次性画出来。这样录制过程中不需要频繁重绘,性能压力小很多。

// 读取 PCM 文件并解析 wx.getFileSystemManager().readFile({ filePath: pcmPath, success(res) { const view = new Int16Array(res.data) const step = Math.floor(view.length / 100) // 抽 100 个点 const wave = [] for (let i = 0; i < 100; i++) { let sum = 0 for (let j = 0; j < step; j++) { sum += Math.abs(view[i * step + j]) } wave.push(sum / step) } this.waveData = wave this.drawWave() } })

这种方案适合"录完再展示"的场景,比如语音日记、朗读打卡。实时波形适合"边录边看"的场景,比如 K 歌、语音评测。选哪种取决于你的产品需求,不要为了炫技硬上实时波形,性能开销和复杂度都不低。

5. 真机调试中那些文档没写的事

代码在开发者工具里跑通不代表真机没问题。音频这块尤其明显,因为涉及硬件和系统权限,模拟器和真机差异很大。下面这些是我在实际调试中踩出来的经验。

5.1 开发者工具与真机的差异

开发者工具里录音用的是电脑的麦克风,格式和真机可能不完全一致。我遇到过在工具里onFrameRecorded正常触发,真机上却因为frameSize设置不当而不触发的情况。所以音频功能一定要在真机上验证,尤其是 iOS 和 Android 各测一遍。

另外,工具里tempFilePath是一个本地路径,真机上可能是wxfile://开头的路径,上传时直接用这个路径就行,uploadFile能识别。但如果你要把它当成本地文件读取,就得用FileSystemManager,路径处理方式略有不同。

5.2 iOS 与 Android 的行为差异

iOS 对音频会话的管理更严格。如果试听时没有正确释放上一个音频对象,再录的时候可能录不进去,因为音频通道被占用了。我的做法是:试听结束后立刻stop()并destroy(),录制前确保没有正在播放的音频。

Android 这边,部分机型在录音时如果同时播放音频,会出现回声或录制到播放的声音。如果产品需要"边放边录",要加回声消除,但小程序层面能做的有限,通常建议避免这种场景。

还有一个差异是暂停行为。iOS 上pause()后切后台再回来,resume()一般能正常工作;Android 上部分机型会直接停止录制。所以切后台的处理要分平台判断,稳妥起见可以在onHide时主动停止并保存已录部分。

5.3 时长与文件大小的实测数据

我实测了几组参数,供参考:

格式采样率码率60秒文件大小音质评价
mp31600048kbps约 360KB人声清晰,够用
aac1600048kbps约 350KB与 mp3 接近
PCM16000无约 1.9MB原始数据,体积大
mp34410096kbps约 720KB音乐级,语音偏浪费

从表里能看出来,人声场景用 16000Hz + 48kbps 的 mp3 是性价比最高的选择,一分钟才 360KB 左右,上传快,音质也够。PCM 只在你需要做音频分析时才用,否则体积是 mp3 的五倍多,上传体验会差很多。

5.4 常见报错与排查路径

录音相关的报错信息往往很模糊,我整理了几个高频问题和排查方向:

  • operateRecorder:fail auth deny:权限被拒,检查scope.record授权状态,引导用户去设置页开启。
  • operateRecorder:fail recorder is recording:重复调用start(),检查状态机,录制中不要再调开始。
  • onStop不触发:可能是duration到了自动停止,或者页面被卸载导致监听丢失,检查监听是否还在。
  • 上传返回 413:文件超过服务端限制,检查文件大小,必要时压缩或限制录制时长。
  • 波形图不显示:检查 canvas 是否拿到了正确的宽高,dpr缩放是否处理,以及waveData是否为空。

排查时我习惯先在onStop里打印tempFilePath和duration,确认录音本身没问题,再去查上传和绘制。把问题分段隔离,比一股脑看代码效率高得多。

6. 把这几块拼成一个可复用的组件

单独实现录制、试听、上传、波形图都不算太难,难的是把它们组合成一个稳定的组件,在多个页面里复用。我的做法是封装一个自定义组件,把状态、音频对象、canvas 都收在组件内部,对外只暴露bind:complete这样的事件,把上传成功后的文件地址抛出去。

组件内部维护一个清晰的状态机,所有按钮的可用性都由状态驱动,避免出现"录制中还能点试听"这种逻辑漏洞。音频对象和 canvas 上下文在组件attached时初始化,detached时销毁,保证不会内存泄漏。波形数据用一个数组缓存,重绘时只重绘变化的部分,减少不必要的性能开销。

这样封装之后,页面里只需要放一个组件标签,传几个配置参数(比如最长时长、是否显示波形),就能得到一个完整的录音模块。后续如果产品要加"变声""降噪"之类的功能,也只需要在组件内部扩展,不影响使用方。

最后分享一个我在实际项目里总结的小技巧:录音按钮的点击反馈一定要及时。用户按下后哪怕还没真正开始录,也要立刻把按钮状态切过去,给一个视觉反馈,因为start()到onStart之间有一小段延迟,如果这段时间界面没反应,用户会以为没点上,然后重复点击,反而触发"recorder is recording"的报错。把状态切换放在调用start()之前,用乐观更新的方式处理,体验会顺滑很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询