返回博客
官小西

FFmpeg 音频混音的三个坑:从 OpenMontage 源码看 LUFS 参数化、asplit ducking 与 amix 静默衰减

用 FFmpeg 做音频混音的人迟早会撞上同一面墙:你写了一条看起来完全合理的 filter_complex,在本机跑得好好的,推到 CI 上就炸了;或者输出听起来没问题,但响度永远比目标平台低 2 dB;又或者旁白在没有人说话的时候清晰,一旦背景乐进来就被压了下去。这些不是偶发 bug——它们是 FFmpeg filtergraph 设计哲学的必然结果,只是文档散落在三十页 man page 里,不读完根本拼不出完整图景。

OpenMontage(calesthio/OpenMontage,截至 2026 年 8 月有 392 次提交)是"第一个开源的 agentic 视频生产系统",它的音频混音模块 tools/audio/audio_mixer.py 最近连续修了两个 commit(0ab97796426662),正好踩中了 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),目的是让用户切换视频/歌曲时不用反复调音量。但各家平台的归一化目标不一样:

各平台 LUFS 标准对比

关键细节: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 和最终混音。旧代码的逻辑是:

  1. amix 把多段 narration 合并成 [speech_mix]
  2. [speech_mix] 喂给 sidechaincompress 做 ducking key
  3. [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]")

修复后的数据流:

full_mix filtergraph 数据流图

[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=0normalize=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 选中输出。

注意最后一个 loudnormI=-14.0——这就是参数化后的 YouTube 目标。如果换成播客,这个值会是 -16.0,其余完全不变。

结论

这三个修复指向同一个教训:FFmpeg 的 filter_complex 不是一条"管道",而是一个有严格拓扑约束的有向无环图(DAG)。每个节点(filter)的输入和输出标签必须精确匹配,每个标签只能有一条出边,filter 的默认行为在组合时可能互相干扰。

在 AI agent 驱动的视频生产管线里,这些约束意味着三件事:

  1. 所有平台相关参数(LUFS、TP、sample rate)必须参数化,并且参数的合法性校验要在进入 ffmpeg 之前完成——你不能假设 agent 传来的值永远是合法的。
  2. filtergraph 必须在所有 ffmpeg 构建上语义一致——asplit 不是优化,是正确性。CI 上的 ffmpeg 是你的"严格模式"测试。
  3. 永远不要依赖 filter 的默认行为来"碰巧"正确——amix normalize=1 在有 loudnorm 时无害,在没有 loudnorm 时致命。显式声明每一个参数。

OpenMontage 的这三个修复加起来只有 84 行代码改动(+84/-2 lines),但它们把一个"在本机能跑"的音频混音工具变成了一个"在 CI 上可靠、在各平台输出合规"的生产级组件。这就是 amateur tooling 和 production tooling 的差距——往往不在功能数量上,而在对边界条件的处理深度上。

参考资料

  1. calesthio — OpenMontage GitHub 仓库tools/audio/audio_mixer.py,commit 0ab9779 + 6426662
  2. FFmpeg — loudnorm filter 官方文档 — 基于 EBU R128 的动态响度归一化
  3. FFmpeg — sidechaincompress filter 官方文档 — 侧链压缩器
  4. FFmpeg — asplit filter 官方文档 — 将音频流分成多份
  5. ITU-R — BS.1770-4: Algorithms to measure audio programme loudness and true-peak audio level — LUFS 标准
  6. EBU — R128: Loudness normalisation and permitted maximum level of audio signals — 欧洲广播联盟响度标准
  7. Apple — Podcasts Specifications for Creators — -16 LUFS / -1 dBTP
  8. Google — YouTube Audio Normalization — -14 LUFS / -1.5 dBTP,只向下归一化
  9. OpenMontage — sound-design.md — 平台响度目标(2025)
  10. OpenMontage — test_audio_mixer_loudnorm_target.py — LUFS 参数化回归测试