提示

返回博客列表

视频音量忽大忽小不只是烦人:EBU R128 响度标准与批量校验

客户提了个听起来很主观的问题:"为什么我们的视频在电视上声音比别的台小,但中间插的广告又特别响?"

我一开始以为是"音量没调好",就把整片的音量提了几个 dB。客户说好了一点,但问题还在——因为根本不是音量问题。

真正的原因是:峰值归一化 vs 响度归一化的区别。我们的视频是"把最高峰调到 0 dB",而广播标准是"把平均响度调到 -23 LUFS"。一段动态很大的内容(安静的对话 + 突然的音效),峰值和响度可以差十几 dB——峰值对齐了,响度就偏小;响度对齐了,峰值又可能过载。

这篇写清楚这套标准和我们的处理流程。

TL;DR:广播和流媒体用的是响度标准(LUFS),不是峰值。常见目标:EBU R128 = -23 LUFS / 真峰值 -1 dBTP;ATSC A/85(美) = -24 LKFS;YouTube / Spotify ≈ -14 LUFS;Netflix ≈ -27 LUFS。ffmpeg 的做法是 loudnorm 双遍:第一遍测量(拿 measured_I 等参数),第二遍用测得的值做精确归一化。单遍 loudnorm 是动态处理,精度差很多。另外两个必须知道的:真峰值(true peak)要留 -1 dB 余量(防止 DAC 后产生削波);LRA(响度范围)决定要不要做动态压缩(播客要压,电影不要压)。

目录

一、峰值不是响度

峰值(peak):波形里最高的那个采样点的值。
响度(loudness):人耳主观感受到的"平均音量",跟能量在时间上的积分有关,还要考虑人耳对不同频率的敏感度(K 加权)。

看两个例子:

内容 峰值 响度(LUFS)
安静的对话(动态大) -1 dB -28 LUFS
压缩过的播客 -1 dB -14 LUFS
交响乐 -1 dB -22 LUFS

三个峰值一样,响度差了 14 dB——这就是"同时播放时有的节目响有的小"的原因。

所以:

  • 只做峰值归一化(ffmpeg -af volume=... 把峰值调到 -1)→ 解决不了响度不一致;
  • 必须做响度归一化(loudnorm)。

二、LUFS 是什么

LUFS(Loudness Units relative to Full Scale)= 相对于满刻度的响度单位。美国标准里叫 LKFS(数值上等价)。

它是一个对数单位(和 dB 一样):

-23 LUFS 比 -14 LUFS 小 9 dB(听起来明显更小)

测量方式(ITU-R BS.1770):

  1. K 加权滤波(模拟人耳的频率响应);
  2. 分 400ms 的块算能量;
  3. 加门限(gating)——去掉极安静的部分(不然整片的安静片段会把平均值拉低);
  4. 得到综合响度(Integrated Loudness),单位 LUFS。

相关指标:

指标 含义 单位
Integrated (I) 整片平均响度 LUFS
True Peak (TP) 真峰值(考虑采样间峰值) dBTP
LRA 响度范围(响度的变化幅度) LU
Momentary / Short-term 瞬时(400ms)/ 短期(3s)响度 LUFS

三、各家标准的目标值

标准/平台 目标响度 真峰值上限 备注
EBU R128(欧洲广播) -23 LUFS -1 dBTP 最经典的标准
ATSC A/85(美国广播) -24 LKFS -2 dBTP
YouTube ≈ -14 LUFS -1 dBTP 会自己做归一化
Spotify ≈ -14 LUFS -1 dBTP
Apple Music ≈ -16 LUFS -1 dBTP
Netflix -27 LUFS -2 dBTP 动态范围保留得多
播客(通用) -16 ~ -18 LUFS -1 dBTP
抖音/短视频 ≈ -14 ~ -16 LUFS -1 dBTP 手机播放,偏响一点

怎么选:

  • 有明确交付方 → 按对方的要求(写进规格文件);
  • 网页/短视频 → -14 ~ -16;
  • 长期归档母版 → 保留原始动态(-23 或者不动),分发时再按平台归一化。

注意:YouTube 和 Spotify 会自动归一化——你上传 -23 LUFS 的内容,它会自动提到 -14。所以"上传前自己提到 -14"其实是没必要的(而且如果超过 -14 它反而会降你的音量)。上传到这类平台,保持 -14 左右即可,不必过度处理。

四、真峰值:为什么 -1 而不是 0

真峰值(True Peak) 考虑的是"采样点之间的峰值"。

数字信号:采样点的值(比如 -0.5 dB)
模拟还原后(DAC):采样点之间可能有更高的尖峰(插值过冲)

所以数字域看起来没削波,还原成模拟信号后可能削波(产生失真)。

测量真峰值需要 4 倍过采样(ffmpeg 的 ebur128 滤镜支持)。

为什么留 -1 dB:

  • 给 DAC 的插值过冲留余量;
  • 给后续的处理(转码、平台再处理)留余量;
  • 有损编码(AAC/MP3)也会让峰值上移一点。

所以即使标准是"不超过 -1 dBTP",我实际会设 TP = -1.5(多留 0.5 dB 保险)。

五、LRA 与动态范围压缩

LRA(Loudness Range) 衡量响度的变化幅度:

LRA 说明
2~5 LU 重度压缩(播客、广告、短视频)
5~10 LU 中等(电视剧、访谈)
10~20 LU 大动态(电影、古典音乐)

ffmpeg 的 loudnorm 里 LRA 参数是"目标 LRA"——如果内容的实际 LRA 超过这个值,编码器会做动态压缩把它压到目标范围。

什么时候该压:

  • ✅ 播客 / 短视频 / 手机播放:环境嘈杂,动态大了听不清 → LRA 目标 5~7;
  • ❌ 电影 / 音乐 / 归档母版:要保留艺术意图 → 不要压(LRA 目标设大一点,比如 11~20,或者干脆不做归一化)。

判断标准:在安静环境下听的内容保留动态,在嘈杂环境下听的内容压缩动态。

六、ffmpeg loudnorm 的正确用法(双遍)

单遍(不精确)

ffmpeg -i input.mp4 -af "loudnorm=I=-16:TP=-1.5:LRA=11" -c:v copy output.mp4

单遍模式下,loudnorm 只能做动态处理(边播边调),精度有限,实测误差可能有 1~2 LU。适合"差不多就行"的场景。

双遍(精确)

第一遍:测量

ffmpeg -i input.mp4 \
  -af "loudnorm=I=-16:TP=-1.5:LRA=11:print_format=json" \
  -f null - 2>&1 | tail -20

输出(JSON):

{
    "input_i" : "-27.34",
    "input_tp" : "-6.10",
    "input_lra" : "8.20",
    "input_thresh" : "-38.19",
    "output_i" : "-16.01",
    "output_tp" : "-1.50",
    "output_lra" : "7.60",
    "output_thresh" : "-26.98",
    "normalization_type" : "dynamic",
    "target_offset" : "0.01"
}

第二遍:用测得的值做线性归一化

ffmpeg -i input.mp4 \
  -af "loudnorm=I=-16:TP=-1.5:LRA=11:measured_I=-27.34:measured_TP=-6.10:measured_LRA=8.20:measured_thresh=-38.19:offset=0.01:linear=true:print_format=summary" \
  -c:v copy -c:a aac -b:a 192k output.mp4

关键参数:

参数 含义
measured_I/TP/LRA/thresh 第一遍测到的值
linear=true 线性归一化(整体增益),不是动态处理
offset 用第一遍的 target_offset,做微调

linear=true 很重要:它让 loudnorm 做一个"整体增益"而不是逐段的动态处理——保留原始动态范围,只把总体响度搬到位。这对音乐/电影类内容是必须的。

注意:linear=true 时,如果原始内容的动态太大,可能无法达到目标 LRA(因为它不做压缩)。这时候 loudnorm 会自动切换到 dynamic 模式并在输出里说明。

完整脚本(自动双遍)

import json
import re
import subprocess
import shlex


def measure(path, target_i=-16, tp=-1.5, lra=11):
    """第一遍:测量响度参数。"""
    af = f'loudnorm=I={target_i}:TP={tp}:LRA={lra}:print_format=json'
    cmd = ['ffmpeg', '-hide_banner', '-i', path, '-af', af, '-f', 'null', '-']
    p = subprocess.run(cmd, capture_output=True, text=True)
    # 从 stderr 末尾抓 JSON
    m = re.search(r'\{[^{}]*"input_i"[^{}]*\}', p.stderr, re.S)
    if not m:
        raise RuntimeError('测量失败:ffmpeg 输出里没找到 JSON')
    return json.loads(m.group(0))


def normalize(path, out, target_i=-16, tp=-1.5, lra=11):
    m = measure(path, target_i, tp, lra)
    af = (
        f'loudnorm=I={target_i}:TP={tp}:LRA={lra}'
        f':measured_I={m["input_i"]}'
        f':measured_TP={m["input_tp"]}'
        f':measured_LRA={m["input_lra"]}'
        f':measured_thresh={m["input_thresh"]}'
        f':offset={m["target_offset"]}'
        f':linear=true:print_format=summary'
    )
    cmd = ['ffmpeg', '-hide_banner', '-y', '-i', path,
           '-af', af, '-c:v', 'copy', '-c:a', 'aac', '-b:a', '192k', out]
    subprocess.run(cmd, check=True, capture_output=True)
    return m

-c:v copy:只处理音频,视频直接复制(快,且不影响画质)。

七、批量测量与校验脚本

先测量全部文件,生成报告,只处理不达标的:

import csv
import subprocess
import re
from pathlib import Path


def ebur128_measure(path):
    """用 ebur128 测量综合响度、真峰值、LRA。"""
    cmd = ['ffmpeg', '-hide_banner', '-nostats', '-i', str(path),
           '-af', 'ebur128=peak=true:framelog=quiet', '-f', 'null', '-']
    p = subprocess.run(cmd, capture_output=True, text=True)
    err = p.stderr

    def grab(pattern, cast=float):
        m = re.search(pattern, err)
        return cast(m.group(1)) if m else None

    return {
        'integrated': grab(r'I:\s*(-?[\d.]+)\s*LUFS'),
        'true_peak': grab(r'Peak:\s*(-?[\d.]+)\s*dBFS'),
        'lra': grab(r'LRA:\s*(-?[\d.]+)\s*LU'),
    }


def batch_report(files, target_i=-16, tol=1.0, tp_limit=-1.0):
    rows = []
    for f in files:
        m = ebur128_measure(f)
        # 判定
        need_fix = False
        reasons = []
        if m['integrated'] is None:
            reasons.append('测量失败')
            need_fix = True
        else:
            if abs(m['integrated'] - target_i) > tol:
                reasons.append(f"响度 {m['integrated']:.1f} != 目标 {target_i}")
                need_fix = True
            if m['true_peak'] is not None and m['true_peak'] > tp_limit:
                reasons.append(f"真峰值 {m['true_peak']:.1f} > {tp_limit}")
                need_fix = True
        rows.append({'file': str(f), **m, 'need_fix': need_fix,
                     'reason': '; '.join(reasons)})
    return rows


if __name__ == '__main__':
    files = sorted(Path('videos').glob('*.mp4'))
    rows = batch_report(files)
    with open('loudness_report.csv', 'w', newline='', encoding='utf-8') as f:
        w = csv.DictWriter(f, fieldnames=['file', 'integrated', 'true_peak',
                                          'lra', 'need_fix', 'reason'])
        w.writeheader()
        w.writerows(rows)
    bad = [r for r in rows if r['need_fix']]
    print(f'共 {len(rows)} 个文件,需要处理 {len(bad)} 个')

这个报告的价值:它能告诉你"这一批内容的响度分布",而且只处理不达标的——已经合规的文件不用重新编码(避免无谓的质量损失)。

八、母版 + 按平台归一化

不要给每个平台做一份音频。 正确做法:

母版(保留原始动态,响度 -23 左右或者不做处理)
   ↓
按平台归一化(各自的目标值)
   ↓
分发

理由:

  1. 母版保留最大动态,将来要适配新平台随时可以做;
  2. 归一化是有损处理(虽然是线性的),做过一次就够了,不要反复做;
  3. 如果先归一化到 -14 再归一化到 -23,会损失动态余量。

存档策略:母版存一份,分发版可以只存音频处理后的文件(或者直接复用视频流 + 替换音轨):

# 只换音轨,视频流直接复制
ffmpeg -i master.mp4 -i normalized_audio.m4a \
  -map 0:v -map 1:a -c:v copy -c:a copy out_youtube.mp4

这样多个平台版本只占一份视频流的空间(音频很小)。

九、实测数据

对 120 个交付视频的测量(处理前):

指标 分布
综合响度 最响 -11.2 LUFS,最轻 -31.8 LUFS,跨度 20.6 LU
真峰值 最高 +0.3 dBTP(已削波),最低 -8.1
LRA 3.2 ~ 18.4 LU
不达标(-16 ± 1) 87 个(72.5%)
真峰值超限(> -1) 14 个(11.7%)

处理后(双遍 loudnorm,目标 -16 / TP -1.5):

指标 结果
达标率 118/120(98.3%)
未达标的 2 个 源本身动态极大(LRA > 20),线性模式下无法达标 → 用 dynamic 模式处理
平均耗时 每个文件 2 次解码,约 40 秒(1 小时的内容)

这个数据说明一件事:我们之前交付的内容里,七成响度不达标——客户抱怨"声音忽大忽小"是有依据的,不是他们挑剔。

十、坑清单

  1. 用峰值归一化当响度归一化 → 完全没用。用 loudnorm。
  2. 只跑单遍 loudnorm → 精度差(1~2 LU 误差)。双遍。
  3. 第二遍忘了传 measured 参数 → 等于单遍。
  4. 不用 linear=true → 动态处理改变了内容的动态范围(音乐/电影会"发闷")。
  5. 真峰值设 0 → 还原后可能削波。设 -1 或 -1.5。
  6. 不测真峰值只测数字峰值 → ebur128 要加 peak=true。
  7. 对所有内容用同一个 LRA 目标 → 音乐/电影要保留动态,播客要压缩。
  8. 上传到 YouTube 前强行提到 -14 → 平台会自己归一化,而且可能反过来压你的。保持 -14 左右即可。
  9. 反复归一化 → 每次处理都有损失。母版只做一次。
  10. 归一化后又做了其它音频处理(降噪、压缩)→ 响度又变了。顺序:降噪 → 归一化(最后一步)。
  11. 只处理了主音轨,忘了第二音轨(多语言)→ 多轨要分别处理。
  12. 视频流也重新编码了 → 用 -c:v copy,只处理音频。
  13. 批量处理时把已经达标的也重编了 → 先测量,只处理不达标的。
  14. 没考虑平台的自动归一化 → 了解目标平台的行为,不要白做。
  15. 响度达标但听起来还是怪 → 可能是频谱问题(不是响度),需要 EQ;或者原始录音本身有问题。响度达标不等于音质好。

最后说说这次之后我的两个习惯改变。

第一:音频处理放在视频流程的最后一步,且验收要测数值。

以前我们的流程是"视频转码完就交付",音频是"顺带"的。现在我会在验收规格里明确写上响度目标值(比如 -16 LUFS ± 1),并且用脚本测量每一个交付文件。这个习惯是从那次客户投诉开始的——它让我意识到:音频问题用户能直接感觉到,而且比画质问题更容易被投诉(因为声音的差异更直觉)。

第二:把"平台的行为"纳入考虑。

同样是"音量",YouTube 会归一化、Netflix 要求 -27、广播电视是 -23——同一份内容在不同渠道的"正确值"是不一样的。所以现在遇到音频需求,我的第一个问题是"这个内容最终在哪里播",而不是"要多大声音"。

这个思路跟前面几篇是相通的:

  • 码率阶梯:先问内容类型和分发方式;
  • 兼容性:先问目标设备;
  • 响度:先问播放平台。

"需求"从来不是一个孤立的技术参数,它总是在某个具体场景下才有意义。 把场景问清楚,后面的技术选择就都是自然的了。

想亲手试试?用 VidDown 一键解析下载

粘贴视频链接即可解析,多平台支持、网页端即用;下载桌面客户端解锁海外平台本地解析,开通会员更享不限次下载。

顶部