前言
做混音、音视频后期的人都绕不开一个问题:我的音频到底够不够响? VU 表和峰值表只能告诉你瞬时电平,无法反映人耳感受到的"主观响度"。EBU R128 标准定义的 LUFS(Loudness Units Full Scale) 才是行业公认的响度度量。本文从零拆解一个基于 FFmpeg + Qt/C++ 的 LUFS 响度分析实现------从 K-weighting 数字滤波、400ms 滑动窗口能量计算,到双门限积分响度算法和 LRA 响度范围,并配以 QML 前端的标尺可视化。
效果图:

一、为什么需要 LUFS
传统的音量表(Peak Meter、VU Meter)测量的是电信号幅度或平均值,但人耳对不同频率的敏感度差异很大:
- 低频(< 100 Hz):即使振幅很大,人耳也不觉得响
- 中高频(2~5 kHz):人耳最敏感的区间,同等振幅听起来最响
- 超高频(> 10 kHz):敏感度再次下降
LUFS 的解决方案是:先用一个"K-weighting"滤波器模拟人耳频率响应,再计算滤波后信号的均方能量。这样得到的数字更接近"人感受到的响度"。
1.1 应用场景
| 场景 | 目标 LUFS | 备注 |
|---|---|---|
| 播客 / 自媒体 | -14 ~ -16 LUFS | YouTube、Spotify 归一化目标 |
| 广播电视 | -23 LUFS ±1 LU | EBU R128 / ATSC A/85 标准 |
| 音乐母带 | -8 ~ -14 LUFS | 流派差异大 |
| 录音质检 | > -30 LUFS | 低于此值可能录音太小 |
二、整体架构
项目采用 C++ 后端 + QML 前端 的分层架构:
┌─────────────────────────┐
│ LufsPage.qml │ ← 前端页面:标尺可视化 + 指标展示
└───────────┬─────────────┘
│ Q_INVOKABLE 调用
┌───────────▼─────────────┐
│ MediaAnalyzer │ ← 门面类:状态管理 + 结果绑定
│ analyzeCurrentLufs() │
└───────────┬─────────────┘
│ 委托
┌───────────▼─────────────┐
│ AudioMetricsAnalyzer │ ← 核心算法:K-weighting + 双门限
│ analyzeLufs() │
└─────────────────────────┘
AudioMetricsAnalyzer:无状态工具类,只读取已解码的 PCM 字节数组,计算 LUFS 指标MediaAnalyzer:暴露给 QML 的门面类,通过Q_PROPERTY绑定lufsInfo给前端LufsPage.qml:展示分析结果,包含可视化标尺、指标表格和参考标准
三、EBU R128 算法详解
EBU R128 标准定义了完整的响度测量流程,核心分为五步:
PCM 原始采样
→ Step 1: K-weighting 滤波(模拟人耳频率响应)
→ Step 2: 400ms 窗口 block 能量计算(75% 重叠)
→ Step 3: 绝对门限 -70 LUFS 过滤极静块
→ Step 4: 相对门限(均值 - 10 LU)二次过滤
→ Step 5: 积分响度 = 通过双门限的 block 均值
3.1 Step 1:K-weighting 数字滤波
K-weighting 由两级 biquad(二阶 IIR)滤波器 级联组成:
Stage 1 --- High Shelf 预滤波:模拟头部和耳廓的声波衍射效应
- 截止频率:1681.97 Hz
- Q 值:0.7071
- 增益:+4 dB
Stage 2 --- RLB(Revised Low-frequency B-weighting)高通滤波:模拟人耳对低频不敏感的特性
- 截止频率:38.14 Hz
- Q 值:0.5003
滤波器的系数根据采样率动态计算:
cpp
void calcKWeightingCoeffs(int sampleRate, BiquadCoeffs &stage1, BiquadCoeffs &stage2)
{
const double fs = static_cast<double>(sampleRate);
// Stage 1: High shelf (fc=1681.97 Hz, Q=0.7071, gain=3.9998 dB)
const double A = std::pow(10.0, 3.999843853501 / 40.0);
const double w0 = 2.0 * M_PI * 1681.974450955533 / fs;
const double alpha = std::sin(w0) / (2.0 * 0.7071752369554196);
const double beta = std::sqrt(A) / 0.7071752369554196;
// ... 计算 b0, b1, b2, a1, a2 系数
// Stage 2: RLB high-pass (fc=38.14 Hz, Q=0.5003)
const double w1 = 2.0 * M_PI * 38.13547087602444 / fs;
// ... 计算高通系数
}
每个 biquad 级使用 Direct Form I 差分方程:
yn=b0xn+b1xn−1+b2xn−2−a1yn−1−a2yn−2 yn = b_0 xn + b_1 xn-1 + b_2 xn-2 - a_1 yn-1 - a_2 yn-2 yn=b0xn+b1xn−1+b2xn−2−a1yn−1−a2yn−2
cpp
double processBiquad(double x, BiquadState &state, const BiquadCoeffs &c)
{
const double y = c.b0 * x + c.b1 * state.x1 + c.b2 * state.x2
- c.a1 * state.y1 - c.a2 * state.y2;
state.x2 = state.x1; state.x1 = x;
state.y2 = state.y1; state.y1 = y;
return y;
}
滤波过程对每个通道独立施加,并在通道间加权求和:
cpp
for (qint64 f = 0; f < frameCount; ++f) {
double sumSq = 0.0;
for (int ch = 0; ch < channels; ++ch) {
double sample = readSample(...) / fullScale;
// 环绕声道 (ch >= 4) 权重 1.41,前向声道 1.0
const double weight = (ch >= 4 && channels > 4) ? 1.41 : 1.0;
sample = processBiquad(sample, stage1State[ch], s1);
sample = processBiquad(sample, stage2State[ch], s2);
sumSq += weight * sample * sample;
}
filteredMsq[f] = sumSq / channels;
}
3.2 Step 2:400ms 窗口 block 能量
EBU R128 定义积分响度的基本单元为 400ms block ,相邻 block 之间 75% 重叠(步进 100ms):
cpp
const int blockFrames = sampleRate * 400 / 1000; // 400ms 对应的帧数
const int hopFrames = blockFrames / 4; // 100ms 步进
for (qint64 start = 0; start + blockFrames <= frameCount; start += hopFrames) {
double energy = 0.0;
for (int i = 0; i < blockFrames; ++i)
energy += filteredMsq[start + i];
energy /= blockFrames;
// 能量转 LUFS:-0.691 是校准常数
const double loudness = energy > 0.0
? -0.691 + 10.0 * std::log10(energy)
: -100.0;
blockLoudness.append(loudness);
}
其中 -0.691 是标准定义的校准偏移量,确保正弦波信号的 LUFS 读数与理论值一致。
3.3 Step 3-4:双门限算法
双门限是 LUFS 的核心设计,目的是排除静音和低电平噪声对平均响度的拉低效应:
第一次门限(绝对门限):过滤 -70 LUFS 以下的 block
cpp
const double absoluteGate = -70.0;
double sumAboveAbs = 0.0;
int countAboveAbs = 0;
for (int i = 0; i < totalBlocks; ++i) {
if (blockLoudness[i] > absoluteGate) {
sumAboveAbs += blockLoudness[i];
++countAboveAbs;
}
}
第二次门限(相对门限):用第一次的均值 - 10 LU 作为新门限
cpp
const double meanAbs = sumAboveAbs / countAboveAbs;
const double relativeGate = meanAbs - 10.0; // 相对门限
// 同时高于绝对门限和相对门限的 block 参与最终计算
for (int i = 0; i < totalBlocks; ++i) {
if (blockLoudness[i] > absoluteGate && blockLoudness[i] > relativeGate) {
sumAboveRel += blockLoudness[i];
++gateBlockCount;
}
}
integratedLufs = sumAboveRel / gateBlockCount;
为什么要两级门限? 假设一段播客中间有 5 秒完全静音:
- 没有门限:静音拉低整体均值,显示 -30 LUFS(偏低)
- 只用绝对门限:-70 LUFS 以下的底噪被过滤
- 加上相对门限:比"平均响度低 10 LU 以上"的段落也被排除(如安静的间奏)
3.4 Step 5:Momentary 和 Short-term 响度
除积分响度外,EBU R128 还定义了两个辅助指标:
- Momentary(瞬态响度):400ms 窗口的最大 LUFS 值
- Short-term(短时响度):3 秒窗口的最大 LUFS 值
cpp
// Momentary:所有 400ms block 中的最大值
double momentaryMax = -100.0;
for (int i = 0; i < totalBlocks; ++i)
momentaryMax = std::max(momentaryMax, blockLoudness[i]);
// Short-term:3 秒滑动窗口的最大 LUFS
const int shortBlockFrames = sampleRate * 3000 / 1000; // 3s
double shortTermMax = -100.0;
for (qint64 start = 0; start + shortBlockFrames <= frameCount; start += hopFrames) {
double energy = 0.0;
for (int i = 0; i < shortBlockFrames; ++i)
energy += filteredMsq[start + i];
energy /= shortBlockFrames;
shortTermMax = std::max(shortTermMax, -0.691 + 10.0 * std::log10(energy));
}
3.5 LRA(Loudness Range)响度范围
LRA 衡量音频的动态范围大小------安静段和响亮段的差异。算法如下:
- 用 3 秒窗口计算所有 Short-term LUFS 值
- 过滤 -70 LUFS 以下的 block
- 计算剩余 block 的均值,用均值 - 20 LU 作为门限
- 对通过门限的 block 取 10% 和 95% 百分位
- LRA = P95 - P10
cpp
if (shortTermValues.size() > 10) {
std::sort(shortTermValues.begin(), shortTermValues.end());
double stMean = 0.0;
for (double v : shortTermValues) stMean += v;
stMean /= shortTermValues.size();
const double stGate = stMean - 20.0; // LRA 用 -20 LU 门限
QVector<double> gated;
for (double v : shortTermValues)
if (v > stGate) gated.append(v);
if (gated.size() > 2) {
const double p10 = gated[gated.size() * 0.10];
const double p95 = gated[gated.size() * 0.95];
loudnessRange = p95 - p10;
}
}
LRA 的实际含义:
- LRA < 5 LU:动态范围小,音量很均匀(新闻播报、压缩过的音乐)
- LRA 5~15 LU:正常的动态范围(播客、流行音乐)
- LRA > 20 LU:动态范围大(古典音乐、未压缩的录音)
四、完整算法流程图
┌───────────────────┐
│ PCM 原始采样值 │
└────────┬──────────┘
│
┌────────▼──────────┐
│ 归一化到 [-1, 1] │
│ ÷ fullScale │
└────────┬──────────┘
│
┌──────────────▼──────────────┐
│ 通道加权 (前向 1.0, 环绕 1.41) │
└──────────────┬──────────────┘
│
┌───────────────────▼───────────────────┐
│ Stage 1: High-shelf (1682 Hz, +4 dB) │
│ 模拟头部衍射 │
└───────────────────┬───────────────────┘
│
┌───────────────────▼───────────────────┐
│ Stage 2: RLB high-pass (38 Hz) │
│ 模拟低频不敏感 │
└───────────────────┬───────────────────┘
│
┌────────▼──────────┐
│ 每帧均方能量求和 │
│ Σ(weight·y²) / N │
└────────┬──────────┘
│
┌──────────────▼──────────────┐
│ 400ms block, 75% 重叠 │
│ E = mean(msq[start..end]) │
└──────────────┬──────────────┘
│
┌────────▼──────────┐
│ L = -0.691 + │
│ 10·log₁₀(E) │
└────────┬──────────┘
│
┌──────────────▼──────────────┐
│ 绝对门限 > -70 LUFS ? │
└──────────────┬──────────────┘
Yes ───┤─── No → 丢弃
│
┌──────────────▼──────────────┐
│ 相对门限 > mean - 10 LU ? │
└──────────────┬──────────────┘
Yes ───┤─── No → 丢弃
│
┌──────────────▼──────────────┐
│ Integrated LUFS = │
│ mean(通过的 block LUFS) │
└─────────────────────────────┘
五、QML 前端可视化
5.1 响度标尺
LUFS 值映射到 -70 ~ 0 的可视化标尺上,用渐变色表示不同响度区间:
qml
// LUFS 值 → 标尺位置 (0.0 ~ 1.0)
function lufsToPosition(lufsValue) {
var v = Number(lufsValue)
if (isNaN(v) || v <= -70) return 0.0
if (v >= 0) return 1.0
return (v + 70.0) / 70.0
}
标尺使用渐变色:
- -70 ~ -40 LUFS(灰色区域):过低,可能需要增益
- -40 ~ -20 LUFS(蓝色区域):正常偏安静
- -20 ~ -10 LUFS(绿色区域):理想区间
- -10 ~ 0 LUFS(橙红区域):过响,可能削波
5.2 指标表格
分析结果通过 InfoPanel 组件展示,包含:
| 字段 | 含义 |
|---|---|
| 集成响度 | 整段音频平均响度 |
| 响度范围 (LRA) | 动态范围大小 |
| 瞬态响度 | 400ms 最大峰值 |
| 短时响度 | 3s 最大峰值 |
| 门限 block 数 | 参与积分计算的 block 数量 |
| 总 block 数 | 全部 400ms block 数量 |
5.3 参考标准
页面底部展示常见场景的目标 LUFS,帮助用户判断分析结果:
- 播客/自媒体:-14 ~ -16 LUFS
- 广播电视:-23 LUFS ±1 LU
- 音乐母带:-8 ~ -14 LUFS
- 质检警示:< -30 LUFS 可能录音过小
六、与 MediaAnalyzer 的集成
6.1 门面层方法
cpp
bool MediaAnalyzer::analyzeCurrentLufs()
{
if (m_pcmData.isEmpty()) {
setStatus(QStringLiteral("请先解码 PCM"));
setLufsInfo(QVariantMap());
return false;
}
setBusy(true);
QString errorText;
QVariantMap result;
const QVariantMap currentPcmInfo = m_mediaInfo.value("pcm").toMap();
const bool ok = m_metricsAnalyzer.analyzeLufs(
m_pcmData, currentPcmInfo, &result, &errorText);
if (ok) {
setLufsInfo(result);
setStatus(QStringLiteral("LUFS 响度分析完成:集成响度 %1")
.arg(result.value("integratedLufs").toString()));
} else {
setLufsInfo(QVariantMap());
setStatus(QStringLiteral("LUFS 分析失败:%1").arg(errorText));
}
setBusy(false);
return ok;
}
6.2 结果自动清空
当用户执行重采样、裁剪、增益等操作导致 PCM 数据更新时,旧的 LUFS 结果会被自动清空------因为 LUFS 分析依赖的 PCM 已经变了:
cpp
void MediaAnalyzer::setPcmData(const QByteArray &data)
{
// ... 清空所有依赖 PCM 的分析结果
setLufsInfo(QVariantMap());
// ...
}
七、性能分析
对于一段 3 分钟 44100 Hz 立体声音频:
| 阶段 | 计算量 | 耗时估计 |
|---|---|---|
| PCM 解码 | ~800 万帧 | ~50ms |
| K-weighting 滤波 | 2 通道 × 800 万次 biquad | ~100ms |
| 400ms block 能量 | ~3000 个 block | ~30ms |
| 双门限计算 | O(N) 遍历 | < 1ms |
| LRA 百分位 | O(N log N) 排序 | ~2ms |
| 总计 | ~200ms |
整体处理速度很快,用户点击"分析"后几乎无感等待。
八、验证方法
要验证实现的正确性,可以用已知 LUFS 值的标准信号做测试:
| 测试信号 | 理论 LUFS |
|---|---|
| 1 kHz 正弦波 @ -20 dBFS (单声道) | -20.69 LUFS |
| 1 kHz 正弦波 @ -20 dBFS (立体声) | -20.69 LUFS |
| 粉红噪声 @ -20 dBFS | ~ -20.69 LUFS |
| 全静音 | -100 LUFS(无有效 block) |
校准常数 -0.691 正是为了在正弦波信号上得到正确读数而引入的:
L=−0.691+10⋅log10(1N∑i=1Nxi2) L = -0.691 + 10 \cdot \log_{10}\left(\frac{1}{N}\sum_{i=1}^{N} x_i^2\right) L=−0.691+10⋅log10(N1i=1∑Nxi2)
九、踩坑记录
9.1 dB 均值 vs 能量均值
严格来说,双门限的第二步应该用线性能量的均值,而不是 dB 值的均值。但本实现为了简化,用了 dB 值均值做近似。对于典型音频内容,两者差异通常在 0.5 LU 以内,不影响实际判断。
9.2 通道加权
EBU R128 对不同声道有不同权重:
- 前向声道(L, R, C, LFE):权重 1.0
- 环绕声道(Ls, Rs 及更高):权重 1.41(+1.5 dB)
这是因为环绕声从侧面/后方到达人耳,同等电平听起来不如前方响。
9.3 短音频处理
当 PCM 不足 400ms 时(如短音效),无法计算积分响度。实现中做了保护:
cpp
if (totalBlocks == 0) {
*errorText = QStringLiteral("PCM 太短,无法计算 LUFS。");
return false;
}
9.4 8-bit PCM 的特殊处理
8-bit PCM 是 unsigned 格式(0~255),需要以 128 为中心转换为有符号振幅:
cpp
if (spec.bitsPerSample == 8) {
const unsigned char value = static_cast<unsigned char>(sample[0]);
return static_cast<double>(value) - 128.0;
}
十、总结
| 维度 | 实现 |
|---|---|
| 标准 | EBU R128(K-weighting + 双门限) |
| 滤波 | 两级 biquad IIR,系数按采样率动态计算 |
| 窗口 | 400ms block,75% 重叠(100ms 步进) |
| 门限 | 绝对门限 -70 LUFS + 相对门限 mean-10 LU |
| 指标 | 集成响度、LRA、Momentary、Short-term |
| 性能 | 3 分钟音频约 200ms |
| UI | 渐变色标尺 + 指标表格 + 参考标准 |
EBU R128 看似复杂,但核心就是三步:滤波 → 分窗统计 → 双门限。理解了 K-weighting 的物理意义(模拟人耳频率响应)和双门限的设计动机(排除静音干扰),实现起来就非常清晰了。