Featured image of post 在腾讯云函数里用 FFmpeg 处理音频

在腾讯云函数里用 FFmpeg 处理音频

录音先存 WAV,再交给云函数转成 MP3 和 M3U8,一条更适合小程序的音频处理链路

前两篇已经把这个小程序的前半段写得差不多了: 先把 CloudBase 环境搭起来,再在录音时拿 PCM 数据实时画波形。

但录音链路真正往后走时,很快就会碰到一个更现实的问题:

录的时候,适合一种格式;存的时候,适合另一种格式;播的时候,往往又是第三种格式更舒服。

我这个“朗读打卡”小程序,最后就是按这个思路拆开的:

录音阶段保存 WAV -> 上传云存储 -> 云函数里用 FFmpeg 转成 MP3 和 M3U8 -> 播放时优先走 HLS

这样做不算炫技,纯粹是工程上更顺。


一、为什么不是一开始就直接录成 MP3

很多人第一反应都会是:既然最后要播、要存,为什么不一开始就直接录成 mp3

原因很简单,因为录音阶段和播放阶段,优化目标根本不是一回事。

在录音页面里,我更在意的是两件事:

  • 能尽快拿到原始音频数据
  • 能稳定做波形反馈和后处理

所以前端录音时,我这边先拿 PCM,再补一个 WAV 头,把文件保存成 wav。这样做好处很直接:

  • wav 封装简单,前端自己就能控制
  • 原始数据保留得更完整,后处理空间大
  • 做实时波形时,不用先面对压缩格式

wav 的问题也同样直接:

  • 文件大
  • 上传慢
  • 不适合长期存储
  • 直接在线播放体验一般

所以如果把 wav 当成最终产物,这条链路其实只完成了一半。

最后更合理的做法,是把格式按阶段拆开:

  • 录音阶段用 wav
  • 存储兼容用 mp3
  • 在线播放优先 m3u8

这里不是在争论哪种格式“更好”,而是每个阶段都用更合适的格式。


二、CloudBase 云函数里怎么把 FFmpeg 带进去

CloudBase 的 Node.js 云函数默认并不自带 FFmpeg,所以这件事最关键的第一步,不是写转换逻辑,而是先把 ffmpegffprobe 放进运行环境。

我这里用的是 Layer

配置Layer

为什么用 Layer

因为它比把二进制直接塞进函数目录里更干净:

  • 多个云函数可以复用
  • 后面升级版本更方便
  • 函数代码目录不会被二进制污染
  • 也是云函数里比较标准的接法

Layer 里要放什么

最基础就是两个可执行文件:

  • ffmpeg
  • ffprobe

目录结构一般像这样:

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,最后再上传回云存储。

这条流程其实很标准:

  1. 从云存储下载原始 wav
  2. 写入 /tmp
  3. ffprobeffmpeg
  4. 生成 mp3m3u8ts
  5. 上传回云端
  6. 清理临时文件

不复杂,但很实用。


三、这条音频转换链路是怎么接进业务里的

我觉得真正有意思的,不是“FFmpeg 能不能跑起来”,而是怎么把它接进业务流程,而且别影响用户录完音之后的体验。

1. 录音结束,先保存原始 WAV

前端录音完成后,会先把 PCM 补成 WAV,再落成文件,然后上传到云存储。

原始文件路径大致是:

{openid}/original/recording_xxx.wav

这个阶段先做两件事:

  • 原始文件保存下来
  • 数据库里先创建录音记录

也就是说,用户完成录音这件事本身,不需要等转码结束。

2. 上传完成后,再异步触发转码

我这里不是让前端直接去调 convertAudioToMp3,而是先走一个更新记录的函数,再由它异步触发后续转换。

这个设计的好处很明显:

  • 用户先拿到“保存成功”的确定性
  • FFmpeg 转码不会阻塞交互
  • 后面即使转码失败,也能单独补偿

说白了,录音完成和格式增强,是两件事,不应该绑死在一次前端等待里。

3. convertAudioToMp3 实际做了什么

这个云函数内部其实就是一条比较标准的服务端音频处理流水线:

  1. 下载原始 wav
  2. 保存到 /tmp/{recordId}.wav
  3. ffprobe 提取音频信息
  4. ffmpeg 转成 mp3
  5. 再生成 HLS 资源,也就是 m3u8 + ts
  6. 上传所有产物回云存储
  7. 更新数据库里的转换状态和文件路径
  8. 清理临时文件

生成后的云存储路径大致会变成:

{openid}/mp3/xxx.mp3
{openid}/hls/xxx/index.m3u8
{openid}/hls/xxx/segment_000.ts
...

数据库里我也会把状态字段一并写回去,比如:

  • mp3FileID
  • mp3CloudPath
  • m3u8FileID
  • m3u8CloudPath
  • hlsSegmentCloudPaths
  • conversion_status
  • conversion_error
  • conversion_time
  • audio_info

这样前端不用靠猜,只要看记录字段就知道现在走到哪一步了。

4. 播放时为什么优先走 M3U8

播放侧的策略我最后也做成了分层回退:

  • 转换完成时,优先播放 HLS
  • 如果转换还没完成,就先播本地临时 wav
  • 本地资源不可用,再回退到云端原文件或 mp3

这里优先 m3u8 的原因也很实际。

对于长一点的音频,HLS 在弱网下会更稳,加载体验也更自然。尤其是小程序这种环境里,流式分片播放通常比一口气拖完整文件更舒服。

另外还有一个小细节。

m3u8 里引用的 ts 分片本身需要可访问地址,但我又不想把这些文件长期公开出去。所以我额外做了一个函数,专门把原始 m3u8 里的分片路径替换成带时效的签名 URL,再把这份“可播放的 manifest”返回给前端。

前端拿到之后,再写成一个本地 .m3u8 文件交给播放器。

这个方案我自己挺喜欢,因为它同时保留了两件事:

  • HLS 的标准播放结构
  • 云存储资源的访问控制

小结

这套方案没有多复杂,但它解决的是一个非常真实的问题:

录音阶段追求实时反馈,存储阶段追求体积可控,播放阶段追求稳定流畅。

所以最后的答案不是“选一种万能格式”,而是把链路拆开:

  • 录音先用 wav
  • 后台转成 mp3
  • 播放优先 m3u8

FFmpeg 放进腾讯云函数 Layer 之后,本质上就是把音频后处理这件事从前端剥离出去。前端专心处理录音和交互,后端负责转码、分发和状态管理,整条链路会顺很多。

对这类小项目来说,我越来越觉得,技术选型最重要的不是“听起来高级”,而是每一段都刚好解决那个阶段的问题。