前两篇已经把这个小程序的前半段写得差不多了: 先把 CloudBase 环境搭起来,再在录音时拿 PCM 数据实时画波形。
但录音链路真正往后走时,很快就会碰到一个更现实的问题:
录的时候,适合一种格式;存的时候,适合另一种格式;播的时候,往往又是第三种格式更舒服。
我这个“朗读打卡”小程序,最后就是按这个思路拆开的:
录音阶段保存 WAV -> 上传云存储 -> 云函数里用 FFmpeg 转成 MP3 和 M3U8 -> 播放时优先走 HLS
这样做不算炫技,纯粹是工程上更顺。
一、为什么不是一开始就直接录成 MP3
很多人第一反应都会是:既然最后要播、要存,为什么不一开始就直接录成 mp3?
原因很简单,因为录音阶段和播放阶段,优化目标根本不是一回事。
在录音页面里,我更在意的是两件事:
- 能尽快拿到原始音频数据
- 能稳定做波形反馈和后处理
所以前端录音时,我这边先拿 PCM,再补一个 WAV 头,把文件保存成 wav。这样做好处很直接:
wav封装简单,前端自己就能控制- 原始数据保留得更完整,后处理空间大
- 做实时波形时,不用先面对压缩格式
但 wav 的问题也同样直接:
- 文件大
- 上传慢
- 不适合长期存储
- 直接在线播放体验一般
所以如果把 wav 当成最终产物,这条链路其实只完成了一半。
最后更合理的做法,是把格式按阶段拆开:
- 录音阶段用
wav - 存储兼容用
mp3 - 在线播放优先
m3u8
这里不是在争论哪种格式“更好”,而是每个阶段都用更合适的格式。
二、CloudBase 云函数里怎么把 FFmpeg 带进去
CloudBase 的 Node.js 云函数默认并不自带 FFmpeg,所以这件事最关键的第一步,不是写转换逻辑,而是先把 ffmpeg 和 ffprobe 放进运行环境。
我这里用的是 Layer。

为什么用 Layer
因为它比把二进制直接塞进函数目录里更干净:
- 多个云函数可以复用
- 后面升级版本更方便
- 函数代码目录不会被二进制污染
- 也是云函数里比较标准的接法
Layer 里要放什么
最基础就是两个可执行文件:
ffmpegffprobe
目录结构一般像这样:
ffmpeg-layer/
└── bin/
├── ffmpeg
└── ffprobe
挂载之后,常见路径通常会落在 /opt/bin/ffmpeg 和 /opt/bin/ffprobe。
我在函数里没有把路径完全写死,而是做了几层兼容:
- 优先读
process.env.FFMPEG_PATH - 优先读
process.env.FFPROBE_PATH - 找不到再尝试
/opt/bin/ffmpeg - 以及
/opt/ffmpeg/bin/ffmpeg这类常见位置
这样后面不管是改 Layer 结构,还是换环境,都会好维护一点。
环境变量最好还是显式配
虽然代码里可以自动探测路径,但我还是更建议把下面两个变量直接配上:
FFMPEG_PATH=/opt/bin/ffmpeg
FFPROBE_PATH=/opt/bin/ffprobe
线上环境里,显式配置通常比“自动猜到”更稳。
为什么所有临时文件都放 /tmp
这个点也挺关键。
腾讯云函数的运行环境不是一个你可以随便写磁盘的完整 Linux 主机,可写目录是有限的。音频处理这类事,基本都得把原始文件先下载到 /tmp,转码后的文件也先落在 /tmp,最后再上传回云存储。
这条流程其实很标准:
- 从云存储下载原始
wav - 写入
/tmp - 调
ffprobe和ffmpeg - 生成
mp3、m3u8、ts - 上传回云端
- 清理临时文件
不复杂,但很实用。
三、这条音频转换链路是怎么接进业务里的
我觉得真正有意思的,不是“FFmpeg 能不能跑起来”,而是怎么把它接进业务流程,而且别影响用户录完音之后的体验。
1. 录音结束,先保存原始 WAV
前端录音完成后,会先把 PCM 补成 WAV,再落成文件,然后上传到云存储。
原始文件路径大致是:
{openid}/original/recording_xxx.wav
这个阶段先做两件事:
- 原始文件保存下来
- 数据库里先创建录音记录
也就是说,用户完成录音这件事本身,不需要等转码结束。
2. 上传完成后,再异步触发转码
我这里不是让前端直接去调 convertAudioToMp3,而是先走一个更新记录的函数,再由它异步触发后续转换。
这个设计的好处很明显:
- 用户先拿到“保存成功”的确定性
- FFmpeg 转码不会阻塞交互
- 后面即使转码失败,也能单独补偿
说白了,录音完成和格式增强,是两件事,不应该绑死在一次前端等待里。
3. convertAudioToMp3 实际做了什么
这个云函数内部其实就是一条比较标准的服务端音频处理流水线:
- 下载原始
wav - 保存到
/tmp/{recordId}.wav - 用
ffprobe提取音频信息 - 用
ffmpeg转成mp3 - 再生成 HLS 资源,也就是
m3u8 + ts - 上传所有产物回云存储
- 更新数据库里的转换状态和文件路径
- 清理临时文件
生成后的云存储路径大致会变成:
{openid}/mp3/xxx.mp3
{openid}/hls/xxx/index.m3u8
{openid}/hls/xxx/segment_000.ts
...
数据库里我也会把状态字段一并写回去,比如:
mp3FileIDmp3CloudPathm3u8FileIDm3u8CloudPathhlsSegmentCloudPathsconversion_statusconversion_errorconversion_timeaudio_info
这样前端不用靠猜,只要看记录字段就知道现在走到哪一步了。
4. 播放时为什么优先走 M3U8
播放侧的策略我最后也做成了分层回退:
- 转换完成时,优先播放 HLS
- 如果转换还没完成,就先播本地临时
wav - 本地资源不可用,再回退到云端原文件或
mp3
这里优先 m3u8 的原因也很实际。
对于长一点的音频,HLS 在弱网下会更稳,加载体验也更自然。尤其是小程序这种环境里,流式分片播放通常比一口气拖完整文件更舒服。
另外还有一个小细节。
m3u8 里引用的 ts 分片本身需要可访问地址,但我又不想把这些文件长期公开出去。所以我额外做了一个函数,专门把原始 m3u8 里的分片路径替换成带时效的签名 URL,再把这份“可播放的 manifest”返回给前端。
前端拿到之后,再写成一个本地 .m3u8 文件交给播放器。
这个方案我自己挺喜欢,因为它同时保留了两件事:
- HLS 的标准播放结构
- 云存储资源的访问控制
小结
这套方案没有多复杂,但它解决的是一个非常真实的问题:
录音阶段追求实时反馈,存储阶段追求体积可控,播放阶段追求稳定流畅。
所以最后的答案不是“选一种万能格式”,而是把链路拆开:
- 录音先用
wav - 后台转成
mp3 - 播放优先
m3u8
FFmpeg 放进腾讯云函数 Layer 之后,本质上就是把音频后处理这件事从前端剥离出去。前端专心处理录音和交互,后端负责转码、分发和状态管理,整条链路会顺很多。
对这类小项目来说,我越来越觉得,技术选型最重要的不是“听起来高级”,而是每一段都刚好解决那个阶段的问题。