客户提了个听起来很主观的问题:"为什么我们的视频在电视上声音比别的台小,但中间插的广告又特别响?"
我一开始以为是"音量没调好",就把整片的音量提了几个 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(响度范围)决定要不要做动态压缩(播客要压,电影不要压)。
目录
- 一、峰值不是响度
- 二、LUFS 是什么
- 三、各家标准的目标值
- 四、真峰值:为什么 -1 而不是 0
- 五、LRA 与动态范围压缩
- 六、ffmpeg loudnorm 的正确用法(双遍)
- 七、批量测量与校验脚本
- 八、母版 + 按平台归一化
- 九、实测数据
- 十、坑清单
一、峰值不是响度
峰值(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):
- K 加权滤波(模拟人耳的频率响应);
- 分 400ms 的块算能量;
- 加门限(gating)——去掉极安静的部分(不然整片的安静片段会把平均值拉低);
- 得到综合响度(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 左右或者不做处理)
↓
按平台归一化(各自的目标值)
↓
分发
理由:
- 母版保留最大动态,将来要适配新平台随时可以做;
- 归一化是有损处理(虽然是线性的),做过一次就够了,不要反复做;
- 如果先归一化到 -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 小时的内容) |
这个数据说明一件事:我们之前交付的内容里,七成响度不达标——客户抱怨"声音忽大忽小"是有依据的,不是他们挑剔。
十、坑清单
- 用峰值归一化当响度归一化 → 完全没用。用 loudnorm。
- 只跑单遍 loudnorm → 精度差(1~2 LU 误差)。双遍。
- 第二遍忘了传 measured 参数 → 等于单遍。
- 不用
linear=true→ 动态处理改变了内容的动态范围(音乐/电影会"发闷")。 - 真峰值设 0 → 还原后可能削波。设 -1 或 -1.5。
- 不测真峰值只测数字峰值 →
ebur128要加peak=true。 - 对所有内容用同一个 LRA 目标 → 音乐/电影要保留动态,播客要压缩。
- 上传到 YouTube 前强行提到 -14 → 平台会自己归一化,而且可能反过来压你的。保持 -14 左右即可。
- 反复归一化 → 每次处理都有损失。母版只做一次。
- 归一化后又做了其它音频处理(降噪、压缩)→ 响度又变了。顺序:降噪 → 归一化(最后一步)。
- 只处理了主音轨,忘了第二音轨(多语言)→ 多轨要分别处理。
- 视频流也重新编码了 → 用
-c:v copy,只处理音频。 - 批量处理时把已经达标的也重编了 → 先测量,只处理不达标的。
- 没考虑平台的自动归一化 → 了解目标平台的行为,不要白做。
- 响度达标但听起来还是怪 → 可能是频谱问题(不是响度),需要 EQ;或者原始录音本身有问题。响度达标不等于音质好。
最后说说这次之后我的两个习惯改变。
第一:音频处理放在视频流程的最后一步,且验收要测数值。
以前我们的流程是"视频转码完就交付",音频是"顺带"的。现在我会在验收规格里明确写上响度目标值(比如 -16 LUFS ± 1),并且用脚本测量每一个交付文件。这个习惯是从那次客户投诉开始的——它让我意识到:音频问题用户能直接感觉到,而且比画质问题更容易被投诉(因为声音的差异更直觉)。
第二:把"平台的行为"纳入考虑。
同样是"音量",YouTube 会归一化、Netflix 要求 -27、广播电视是 -23——同一份内容在不同渠道的"正确值"是不一样的。所以现在遇到音频需求,我的第一个问题是"这个内容最终在哪里播",而不是"要多大声音"。
这个思路跟前面几篇是相通的:
- 码率阶梯:先问内容类型和分发方式;
- 兼容性:先问目标设备;
- 响度:先问播放平台。
"需求"从来不是一个孤立的技术参数,它总是在某个具体场景下才有意义。 把场景问清楚,后面的技术选择就都是自然的了。