用 FFmpeg avfilter 实现多路音频混合:RambosPlayer 音频混合功能原理
功能描述
RambosPlayer 的音频混合面板(Ctrl+Shift+A)允许用户在源视频的任意时间点叠加外部音频:
可以是 BGM、配音片段、音效等,每段音频可以独立设置贴入时间、来源偏移、持续时长和混合音量,同时支持将源音频在该区间内自动压低(闪避效果)。
github: https://github.com/johnjiamzhong-project/RambosPlayer
整个处理分两个步骤:
- 混音:用 FFmpeg avfilter 把所有音频流叠加 → 输出临时 AAC
- 封装:把源视频的视频流 + 新 AAC 按 DTS 顺序交织写入最终 MP4

步骤一:avfilter 图的构建
核心是 buildFilterStr() 动态拼出一个 avfilter 字符串。
假设源视频音频为 in0,用户添加了 2 个区间(in1、in2),最终生成:
[in0]volume=volume=0.200:enable='between(t,10.000,40.000)'[src_adj];
[in1]atrim=start=0.000:end=30.000,asetpts=PTS-STARTPTS,adelay=10000|10000,apad,volume=0.800[add1];
[in2]atrim=start=5.000:end=20.000,asetpts=PTS-STARTPTS,adelay=60000|60000,apad,volume=1.000[add2];
[src_adj][add1][add2]amix=inputs=3:normalize=0:duration=first[out]
逐段拆解:
① 源音频分段降音(闪避)
[in0]volume=volume=0.2:enable='between(t,10,40)'[src_adj]
如果用户设置了 srcVol < 1.0,就在对应时间窗内用 volume 滤镜压低源音频。
关键点:多个区间可能时间重叠,不能串联两个 volume 滤镜(0.5 × 0.5 = 0.25,音量过低)。
实现方式:把所有区间边界点排序,对每个子段取覆盖它的所有区间中最小的 srcVol,
每个子段只生成一个 volume 节点:
```cpp
for (size_t bi = 0; bi + 1 < bounds.size(); ++bi) {
double mid = (bounds[bi] + bounds[bi+1]) * 0.5;
float minVol = 1.0f;
for (const auto& r : task_.regions)
if (mid 覆盖在 r 的范围内 && r.srcVol < 0.999f)
minVol = std::min(minVol, r.srcVol);
// 只生成一个 volume 节点
}
② 附加音频的 atrim + adelay
[in1]atrim=start=0:end=30,asetpts=PTS-STARTPTS,adelay=10000|10000,apad,volume=0.8[add1]
- atrim:截取来源音频的指定片段(从哪里开始,取多长)
- asetpts=PTS-STARTPTS:trim 后重置时间戳从 0 开始,否则 adelay 计算会偏移
- adelay=10000|10000:延迟 10000ms(即 10 秒),把这段音频"贴"到视频的 10 秒位置
- apad:末尾补静音,防止短音频导致 amix 提前截断
- volume:混合时的相对音量
③ amix 混合
[src_adj][add1][add2]amix=inputs=3:normalize=0:duration=first[out]
- normalize=0:不做响度归一化,直接叠加(用户自己控制各路音量)
- duration=first:以 src_adj(源视频音频)的时长为准,超出的附加音频直接截掉
步骤二:多路解码 + 单路编码
构建好滤镜图后,N+1 个输入(1 个源视频音频 + N 个附加音频文件)同时解码,
解码帧送入各自的 abuffersrc,混合后从 abuffersink 取出,用 AAC 编码写入临时文件。
主循环采用轮询式喂帧策略——每轮依次给每个源各喂一帧,
避免某一路先跑完 EOF 导致 amix 内部缓冲饥饿:
while (activeSrcs > 0) {
for (int i = 0; i <= N; ++i) {
if (srcs[i].eof) continue;
// 读一帧 → 解码 → 送入 abuffersrc
}
// 从 abuffersink 取混合结果 → AAC 编码 → 写临时文件
}
步骤三:封装最终 MP4
临时 AAC 生成后,最后一步把它和源视频的视频流一起封装:
| 输入 | 操作 | 输出 |
|---|---|---|
| 源视频 video stream(copy) | av_interleaved_write_frame | 输出 MP4 |
| 临时 AAC audio stream | ↑ 按 DTS 交织写入 | movflags=faststart |
视频流直接 copy,不重编码,保留原始画质。
写入时按两路的 DTS 大小交替写,保证 MP4 容器内帧的时间顺序正确:
int64_t vDts = av_rescale_q(videoPkt->dts, srcVideoTB, ovs->time_base);
int64_t aDts = av_rescale_q(audioPkt->dts, srcAudioTB, oas->time_base);
writeVideo = (vDts <= aDts);
最后加 movflags=faststart,把 moov box 挪到文件开头,方便边下边播。
● mermaid
flowchart TD
config["用户配置\n区间列表 [贴入点, 偏移, 时长, 混合音量, 源音量]"]
config --> build["buildFilterStr()\n动态拼接 avfilter 字符串"]
subgraph filter["FFmpeg avfilter 图(execMixAudio)"]
src0["abuffersrc[0]\n源视频音频"]
src1["abuffersrc[1..N]\n各附加音频"]
src0 --> vol["volume\n分段闪避 srcVol"]
vol --> src_adj["[src_adj]"]
src1 --> atrim["atrim\n截取偏移+时长"]
atrim --> adelay["adelay\n延迟到贴入点"]
adelay --> apad["apad\n末尾补静音"]
apad --> mvol["volume\n混合音量"]
mvol --> add1["[add1..N]"]
src_adj --> amix["amix\nnormalize=0 / duration=first"]
add1 --> amix
amix --> sink["abuffersink"]
end
build --> filter
sink --> enc["AAC 编码器 192kbps"]
enc --> aac["临时 .aac"]
subgraph mux["封装(execMuxFinal)"]
video["源视频视频流\ncopy 不重编码"]
aac2["临时 .aac"]
video --> interleave["DTS 交织写入"]
aac2 --> interleave
end
aac --> aac2
interleave --> out["输出 MP4\nmovflags=faststart"]
一个踩过的坑
初版直接用 avfilter_graph_parse2() 解析滤镜字符串,再手动 link。
后来发现 avfilter_graph_parse_ptr() 返回的 gInputs 链表节点名称就是滤镜字符串里的 in0、in1……
只需遍历链表按名字匹配,然后把对应的 abuffersrc link 上去即可:
for (AVFilterInOut* cur = gInputs; cur; cur = cur->next) {
int idx = QString(cur->name).mid(2).toInt(); // "in0" → 0
avfilter_link(srcs[idx].bufSrc, 0, cur->filter_ctx, cur->pad_idx);
}
另一个坑是 abuffersrc 的 time_base 必须和对应解码器的流 time_base 一致,
不能用 {1, sample_rate},否则 amix 内部 PTS 对齐出错,BGM 混入后无声。

浙公网安备 33010602011771号