用 FFmpeg 做音频混音的人迟早会撞上同一面墙:你写了一条看起来完全合理的 filter_complex,在本机跑得好好的,推到 CI 上就炸了;或者输出听起来没问题,但响度永远比目标平台低 2 dB;又或者旁白在没有人说话的时候清晰,一旦背景乐进来就被压了下去。这些不是偶发 bug——它们是 FFmpeg filtergraph 设计哲学的必然结果,只是文档散落在三十页 man page 里,不读完根本拼不出完整图景。
OpenMontage(calesthio/OpenMontage,截至 2026 年 8 月有 392 次提交)是"第一个开源的 agentic 视频生产系统",它的音频混音模块 tools/audio/audio_mixer.py 最近连续修了两个 commit(0ab9779 和 6426662),正好踩中了 FFmpeg 音频处理最典型的三个坑。这三个修复都不是"换个参数"那么简单——它们揭示的是:在一条用 AI agent 驱动的视频生产管线里,音频混音的可靠性边界在哪里。
第一个坑:loudnorm 的 LUFS 目标被硬编码了
什么是 LUFS,为什么 -16 和 -14 不是"音量大小"的区别
LUFS(Loudness Units Full Scale)是 ITU-R BS.1770 定义的感知响度标准,和 dBFS(峰值电平)有本质区别:dBFS 测的是波形最高点,LUFS 测的是人耳实际感知到的平均响度。两段峰值都是 -3 dBFS 的音频,LUFS 可能差 10 个单位——因为一个可能是短促的鼓点,另一个是持续的白噪声。
流媒体平台用 LUFS 做响度归一化(loudness normalization),目的是让用户切换视频/歌曲时不用反复调音量。但各家平台的归一化目标不一样:
关键细节:YouTube 只向下归一化,不做提升。你交付 -16 LUFS 的视频,YouTube 不会帮你拉到 -14,它只会安静地保持 -16。这意味着同一个视频在 YouTube 上听起来比平台上其他 -14 的内容偏轻。而 Apple Podcasts 恰恰相反,它给语音内容留了更多 headroom,目标是 -16 LUFS。
OpenMontage 的修复:把 -16 从硬编码变成参数
OpenMontage 的 audio_mixer.py 原来是这样写 loudnorm 的:
# 旧代码:硬编码 -16
filter_parts.append("[mixed]loudnorm=I=-16:LRA=11:TP=-1.5[out]")
这里 I=-16 就是 Integrated Loudness 目标值。问题是,OpenMontage 自己的 sound-design.md 明确写着:
TARGET LUFS: -14 LUFS (YouTube/TikTok/IG) | -16 LUFS (podcasts)
即同一个项目的设计文档说 YouTube 要 -14,代码却永远输出 -16。在 AI 视频管线里这尤其危险:导演指令(edit_decisions.metadata.loudnorm_target)可能声明了目标平台是 YouTube,但混音工具完全忽略这个字段,默默输出播客级别的响度。
修复方案是把 LUFS 目标参数化:
@staticmethod
def _loudnorm_filter(inputs: dict[str, Any], in_label: str, out_label: str) -> str:
"""Build a loudnorm filter graph edge honoring the per-call LUFS target."""
target = inputs.get("loudnorm_target", -16)
try:
target = float(target)
except (TypeError, ValueError):
target = -16.0
# Clamp to a sane loudness range to avoid malformed ffmpeg args.
target = max(-40.0, min(0.0, target))
return f"[{in_label}]loudnorm=I={target}:LRA=11:TP=-1.5[{out_label}]"
这段代码做了三件事:(1) 从输入参数读取目标值,默认仍然是 -16;(2) 防御性 float() 转换,非数字输入退回默认;(3) clamp 到 [-40, 0] 范围,防止异常值生成无效的 ffmpeg 参数。
对应的测试文件 tests/tools/test_audio_mixer_loudnorm_target.py 用五个 case 锁定了这些边界:
def test_default_target_is_podcast_minus_16():
assert "I=-16" in _filter({})
def test_youtube_target_minus_14_is_honored():
assert "I=-14" in _filter({"loudnorm_target": -14})
def test_out_of_range_target_is_clamped():
assert "I=0.0" in _filter({"loudnorm_target": 99})
def test_non_numeric_target_falls_back_to_default():
assert "I=-16" in _filter({"loudnorm_target": "not-a-number"})
这正是生产级 AI 管线需要的防御策略:参数的合法性边界必须在进入 ffmpeg 之前用代码保证,不能依赖 ffmpeg 的报错信息——因为 ffmpeg 对无效参数的行为是 undefined 的,不同构建版本可能默默接受或直接 panic。
loudnorm 的三个参数:I、LRA、TP
理解这个 filter 还需要知道三个参数的含义:
| 参数 | 含义 | OpenMontage 的值 | 说明 |
|---|---|---|---|
I (Integrated) |
整体响度目标 | -16 或 -14(参数化) | 最关键的一个,对应平台 LUFS 标准 |
LRA (Loudness Range) |
响度动态范围 | 11 | 控制最响和最安静部分的差距,11 是保守值 |
TP (True Peak) |
真实峰值上限 | -1.5 dBTP | 防止 intersample peak 导致削波 |
这里有一个值得注意的细节:TP=-1.5 对应 YouTube 的 -1.5 dBTP 标准,但 TikTok 用 -1 dBTP,Apple Podcasts 也用 -1 dBTP。OpenMontage 目前没有参数化 TP——如果未来要支持 Apple Podcasts 严格合规,这也是需要改的。
第二个坑:filtergraph 标签只能消费一次
sidechain ducking 的工作原理
在进入这个 bug 之前,先说清楚"ducking"是什么。Ducking(闪避)是指:当旁白(speech)响起时,自动压低背景音乐(music)的音量;当旁白结束时,音乐恢复。这是播客、解说视频、纪录片的标准音频处理手法。
FFmpeg 用 sidechaincompress 实现 ducking,工作原理是:一个音频信号(speech)作为"sidechain key"输入,另一个音频信号(music)是"主输入"。当 sidechain key 的能量超过 threshold 时,压缩器开始工作,压低主输入的音量。
┌─────────┐ ┌─────────────────────┐
│ speech │──→ sidechain key ──→ │ │
└─────────┘ │ sidechaincompress │──→ ducked music
│ (music 被压缩) │
┌─────────┐ │ │
│ music │──→ main input ──────→ │ │
└─────────┘ └─────────────────────┘
关键点:sidechaincompress 只输出被压缩后的 music,不输出 speech。speech 只是作为"控制信号"参与运算,不会出现在输出流里。所以最终混音时,你还需要一份 speech 叠加上去。
旧代码的致命错误:复用同一个标签
OpenMontage 的 _full_mix 操作需要同时做 ducking 和最终混音。旧代码的逻辑是:
- 用
amix把多段 narration 合并成[speech_mix] - 把
[speech_mix]喂给sidechaincompress做 ducking key - 再用
[speech_mix]参与amix最终混音
看起来没问题——但 FFmpeg filtergraph 有一条硬规则:一个 filtergraph 输出标签只能被消费一次。[speech_mix] 在步骤 2 已经被 sidechaincompress 消费了,步骤 3 再次引用它就是非法的。
在 macOS 的 Homebrew ffmpeg 构建上,这个错误被默默容忍了——某些 ffmpeg 版本会自动 split 被多次引用的标签。但在 CI 的 Linux ffmpeg 构建上,严格的标签检查直接拒绝了整个 filtergraph,导致 CI 全面失败。
旧代码的注释暴露了当时的困惑和反复挣扎:
# sidechaincompress uses the speech signal only as the ducking key —
# it does not emit speech to the output. Re-derive the speech stream
# for the final mix below. (An earlier version also appended an
# `acopy[speech_dup]` here, but that pad was never consumed and left
# the filtergraph with a dangling output, which ffmpeg rejects — so
# single-narration + music full_mix always failed. FFmpeg auto-splits
# the reused input label, so no explicit duplicate is needed.)
这段注释本身就是一篇 debugging 日志:先试了 acopy 复制,结果产生 dangling output(悬空输出,FFmpeg 不允许有未被消费的输出);然后改为复用标签,依赖"FFmpeg 自动 split"的行为——结果这在 CI 上又失败了。
修复:用 asplit 显式分叉
正确的做法是用 asplit 在消费之前把 speech 流显式分成两份:
# Build ONE speech stream, then split it into two independent
# branches: one feeds the sidechain compressor as the ducking key,
# the other is mixed into the final output. A filtergraph label may
# only be consumed once, so reusing the same speech label for both
# the sidechain key and the output mix is invalid on stricter ffmpeg
# builds (e.g. the Linux ffmpeg on CI). asplit makes the fork explicit.
speech_indices = list(range(len(speech_tracks)))
speech_labels = "".join(f"[a{i}]" for i in speech_indices)
if len(speech_tracks) > 1:
filter_parts.append(
f"{speech_labels}amix=inputs={len(speech_tracks)}:duration=longest[speech_all]"
)
else:
filter_parts.append(f"[a{speech_indices[0]}]acopy[speech_all]")
filter_parts.append("[speech_all]asplit=2[speech_key][speech_out]")
修复后的数据流:
[speech_all]asplit=2[speech_key][speech_out] 这一行是修复的核心。它把 speech 流在进入后续消费者之前,显式分叉成两条独立的流:[speech_key] 喂给 sidechaincompress,[speech_out] 喂给最终 amix。每条流各自只被消费一次,filtergraph 完全合法。
对应的 sidechaincompress 调用也更新了:
# 修复后:用 [speech_key] 而非复用 [speech_mix]
filter_parts.append(
f"{music_in}[speech_key]sidechaincompress="
f"threshold=0.02:ratio=9:attack={attack}:release={release}:"
f"level_sc=1:mix=0.9[ducked_music];"
f"[ducked_music]volume={music_vol * 3}[music_out]"
)
# Final mix: the other speech branch + ducked music
mix_label = "[speech_out][music_out]amix=inputs=2:duration=longest[premix]"
sidechaincompress 参数解读
这里几个参数值得深入理解:
| 参数 | 值 | 含义 |
|---|---|---|
threshold |
0.02 | speech 能量超过 0.02(约 -34 dB)时触发压缩 |
ratio |
9 | 9:1 压缩比——比较激进的 ducking |
attack |
200ms | speech 出现后 200ms 内完成压低——给人耳"平滑过渡"的感觉 |
release |
500ms | speech 消失后 500ms 恢复——避免音乐"跳动" |
level_sc |
1 | sidechain 信号不做额外增益 |
mix |
0.9 | wet/dry 混合,90% 压缩信号 + 10% 原始信号 |
随后的 volume={music_vol * 3} 是对 ducked music 做补偿增益。music_vol 默认 0.15(即 ducking 后音乐保留 15% 的能量),乘以 3 得到 0.45 的线性增益,确保 ducked music 在混音中不会太低。
这个设计选择和 Orca 的工程纪律 有异曲同工之处:当你在一条 AI agent 驱动的生产线上做音频处理,你不能依赖"ffmpeg 在我的机器上能跑"——你必须写出在所有 ffmpeg 构建上都语义一致的 filtergraph。asplit 不是性能优化,是正确性保证。
第三个坑:amix 的 normalize=1 会偷偷衰减语音
问题场景
OpenMontage 还有一个 _segmented_music 操作,用于在视频的特定时间段插入背景音乐(比如"解说时静音,展示画面时放音乐")。这个操作用了 FFmpeg 的 volume 表达式来控制音乐在各时间段的音量:
# music_volume 表达式:在 segment 内播放,外面静音,边界做淡入淡出
volume='{vol_expr}':eval=frame
然后把它和 speech 一起 amix:
# 旧代码
f"[speech][music_fmt]amix=inputs=2:duration=first:dropout_transition=2[aout]"
看起来没问题。但 amix 有一个默认参数 normalize=1(在某些 FFmpeg 版本里也叫 normalize,某些版本叫 weights 行为),它的作用是:把每个输入除以输入数。两个输入,每个都被乘以 0.5——也就是 -6dB。
在 _mix 和 _full_mix 操作里,这个问题被 loudnorm 遮蔽了:最终输出会经过 loudnorm 把整体响度拉回目标值,所以 -6dB 的衰减被补偿了。但 _segmented_music 路径没有 loudnorm 阶段——衰减是永久的。
更隐蔽的是:这 -6dB 衰减作用在所有输入上,包括 speech。也就是说,即使背景音乐的 volume 表达式在某段时间是 0(完全静音),speech 仍然被衰减了 -6dB。这对旁白清晰度的影响是毁灭性的。
修复:normalize=0
# normalize=0: amix's default normalize=1 divides every input by the
# input count (here x0.5 / -6 dB), which would permanently attenuate
# the narration across the whole timeline — including stretches where
# the music volume expression is 0. The music is already scaled by the
# `volume` expression, so speech must pass at unity. Unlike _mix/
# _full_mix, this path has no loudnorm stage to mask the halving.
f"[speech][music_fmt]amix=inputs=2:duration=first:dropout_transition=2:normalize=0[aout]"
加 normalize=0 后,amix 不再做自动除法,每个输入以原始电平参与混音。music 已经被 volume 表达式缩放过了,speech 以 unity(原始电平)通过。
这是一个典型的"两个 bug 互相抵消,修掉一个暴露另一个"的场景。在 _full_mix 路径里,amix 的 normalize 衰减被 loudnorm 补偿了,所以没人发现。一旦你走到一条没有 loudnorm 的路径(_segmented_music),bug 就暴露了。如果你想在整条管线上实现一致的行为,正确做法是在所有 amix 调用上显式声明 normalize 策略,而不是依赖下游 filter 来"碰巧"补偿。
为什么这三个修复对生产级 AI 视频管线重要
1. 参数化的深度决定了管线的灵活性
把 LUFS 目标从硬编码变成参数,表面上是加了一个 loudnorm_target 字段。但真正的意义是:它打通了从导演指令(edit_decisions.metadata)到执行层(ffmpeg 参数)的完整链路。在一个 AI agent 驱动的系统里,这意味着导演可以说"这个视频发 YouTube",系统就能自动把 LUFS 目标设为 -14——不需要人在中间改代码。
这也是为什么 OpenMontage 选择在 _loudnorm_filter 里做 clamp 和类型转换,而不是把校验推给 JSON schema 验证:AI agent 传进来的参数可能是任何形状的。一个 string 类型的 -14、一个 NaN、一个超出物理意义的值——代码必须能优雅地处理所有情况,因为 agent 不会读你的 API 文档。
2. CI 可移植性是"正确性"的一部分
asplit 修复揭示了一个更普遍的 FFmpeg 原则:filtergraph 的语义不能依赖特定构建的"宽容行为"。如果你的 filtergraph 在 macOS Homebrew ffmpeg 上能跑,但在 Linux CI 的 ffmpeg 上炸了,那不是"CI 环境有问题"——是你的 filtergraph 从一开始就是非法的,只是被宽松的构建容忍了。
| FFmpeg filtergraph 规则 | 违反后果 | 何时暴露 |
|---|---|---|
| 输出标签只能被消费一次 | filtergraph 被拒绝 | 换 ffmpeg 构建时 |
| 所有标签必须有消费者 | "dangling output" 报错 | 始终 |
| 输入标签必须存在 | filtergraph 被拒绝 | 始终 |
asplit 是让 filtergraph 在所有 ffmpeg 构建上语义一致的唯一可靠方式。在 AI 视频管线里,这意味着你的 agent 生成的音频处理指令可以在开发机、CI 服务器、生产环境上产出完全一致的输出——这是"可重现构建"的前提。
3. 默认参数的连锁效应
amix normalize 的问题展示了 FFmpeg 一个更大的设计哲学:很多 filter 有隐式的"智能"默认行为,这些行为在简单场景下是有益的,但在组合使用时会互相干扰。normalize=1 在纯混音场景下是合理的(防止多路输入叠加后削波),但一旦上游有 volume 做了精确电平控制、下游又没有 loudnorm 补偿,这个"智能"默认就成了 bug 的根源。
在一个有多个混音路径的系统里(OpenMontage 有 _mix、_duck、_full_mix、_segmented_music 四条路径),你需要确保每条路径对 amix 的 normalize 行为有一致的预期。最安全的做法是显式声明 normalize=0 或 normalize=1,永远不依赖默认值——因为默认值在不同 FFmpeg 版本之间可能会变。
对于同样关注 AI Agent 驱动的媒体管线可靠性的人,我在另一篇文章里拆解过 ntfy vs Gotify vs Nostr 的推送方案对比,那是 agent 系统的"最后一公里"——cron 结果怎么推到手机。音频混音则是"第一公里":agent 生成的视频能不能在各个平台上听起来正常。
完整的 full_mix filter_complex 示例
把三个修复合在一起,下面是一个完整的 _full_mix 调用生成的 filter_complex(单段旁白 + 单段背景音乐 + ducking + loudnorm,目标 YouTube -14 LUFS):
# 输入: -i narration.wav -i bgm.mp3
[a0]acopy[speech_all];
[a1]acopy[music_in_raw];
[speech_all]asplit=2[speech_key][speech_out];
[music_in_raw][speech_key]sidechaincompress=threshold=0.02:ratio=9:attack=0.2:release=0.5:level_sc=1:mix=0.9[ducked_music];
[ducked_music]volume=0.45[music_out];
[speech_out][music_out]amix=inputs=2:duration=longest[premix];
[premix]loudnorm=I=-14.0:LRA=11:TP=-1.5[out]
读这个 filtergraph 的方式是从上到下、从左到右追踪标签:[a0] 和 [a1] 是 FFmpeg 输入流的音频轨道;每个 ; 分隔一个 filter 操作;标签在方括号里传递;asplit 把一条流变成两条;最终 [out] 被 -map 选中输出。
注意最后一个 loudnorm 的 I=-14.0——这就是参数化后的 YouTube 目标。如果换成播客,这个值会是 -16.0,其余完全不变。
结论
这三个修复指向同一个教训:FFmpeg 的 filter_complex 不是一条"管道",而是一个有严格拓扑约束的有向无环图(DAG)。每个节点(filter)的输入和输出标签必须精确匹配,每个标签只能有一条出边,filter 的默认行为在组合时可能互相干扰。
在 AI agent 驱动的视频生产管线里,这些约束意味着三件事:
- 所有平台相关参数(LUFS、TP、sample rate)必须参数化,并且参数的合法性校验要在进入 ffmpeg 之前完成——你不能假设 agent 传来的值永远是合法的。
- filtergraph 必须在所有 ffmpeg 构建上语义一致——
asplit不是优化,是正确性。CI 上的 ffmpeg 是你的"严格模式"测试。 - 永远不要依赖 filter 的默认行为来"碰巧"正确——
amix normalize=1在有 loudnorm 时无害,在没有 loudnorm 时致命。显式声明每一个参数。
OpenMontage 的这三个修复加起来只有 84 行代码改动(+84/-2 lines),但它们把一个"在本机能跑"的音频混音工具变成了一个"在 CI 上可靠、在各平台输出合规"的生产级组件。这就是 amateur tooling 和 production tooling 的差距——往往不在功能数量上,而在对边界条件的处理深度上。
参考资料
- calesthio — OpenMontage GitHub 仓库 —
tools/audio/audio_mixer.py,commit0ab9779+6426662 - FFmpeg — loudnorm filter 官方文档 — 基于 EBU R128 的动态响度归一化
- FFmpeg — sidechaincompress filter 官方文档 — 侧链压缩器
- FFmpeg — asplit filter 官方文档 — 将音频流分成多份
- ITU-R — BS.1770-4: Algorithms to measure audio programme loudness and true-peak audio level — LUFS 标准
- EBU — R128: Loudness normalisation and permitted maximum level of audio signals — 欧洲广播联盟响度标准
- Apple — Podcasts Specifications for Creators — -16 LUFS / -1 dBTP
- Google — YouTube Audio Normalization — -14 LUFS / -1.5 dBTP,只向下归一化
- OpenMontage — sound-design.md — 平台响度目标(2025)
- OpenMontage — test_audio_mixer_loudnorm_target.py — LUFS 参数化回归测试