音频均衡器(EQ)实现:Peaking EQ 双二阶数字滤波器

概述

均衡器(Equalizer)是音频处理中最常用的工具之一,用于对不同频率区间的信号进行增益或衰减调整。本文介绍如何在 Qt/C++ 音频工具中,使用 Peaking EQ 双二阶(biquad)数字滤波器 直接在 PCM 浮点数据上实现 5 频段均衡器,完全绕过 FFmpeg 滤镜图,避免了滤镜解析的兼容性问题。

效果图:

技术选型

为什么不用 FFmpeg firequalizer?

最初方案是使用 FFmpeg 的 firequalizer 滤镜,但在实际开发中遇到了一系列问题:

  1. avfilter_graph_parse_ptr 字符串解析器 按冒号 : 拆分滤镜参数,gain='f=60:g=0.0;f=250:g=3.0;...' 中的冒号被错误拆分
  2. 即使改用程序化 API(avfilter_graph_alloc_filter + av_opt_set),firequalizer 的 gain 参数在不同 FFmpeg 版本中行为不一致
  3. 最终决定彻底移除 FFmpeg 滤镜图,改用纯 C++ biquad 滤波器直接在 PCM 数据上处理

Peaking EQ Biquad 滤波器

采用 Robert Bristow-Johnson 的 Audio Cookbook 中的 Peaking EQ 公式:

A=10gainDb/40 A = 10^{\text{gainDb}/40} A=10gainDb/40

ω=2πf0/fs \omega = 2\pi f_0 / f_s ω=2πf0/fs

α=sin⁡(ω)/(2Q) \alpha = \sin(\omega) / (2Q) α=sin(ω)/(2Q)

滤波器系数:

分子 分母
b0=1+αAb_0 = 1 + \alpha Ab0=1+αA a0=1+α/Aa_0 = 1 + \alpha / Aa0=1+α/A
b1=−2cos⁡(ω)b_1 = -2\cos(\omega)b1=−2cos(ω) a1=−2cos⁡(ω)a_1 = -2\cos(\omega)a1=−2cos(ω)
b2=1−αAb_2 = 1 - \alpha Ab2=1−αA a2=1−α/Aa_2 = 1 - \alpha / Aa2=1−α/A

差分方程:

yn=b0xn+b1xn−1+b2xn−2−a1yn−1−a2yn−2a0 yn = \frac{b_0 xn + b_1 xn-1 + b_2 xn-2 - a_1 yn-1 - a_2 yn-2}{a_0} yn=a0b0xn+b1xn−1+b2xn−2−a1yn−1−a2yn−2

整体架构

复制代码
┌─────────┐    ┌────────────┐    ┌─────────────┐    ┌────────────┐    ┌─────────┐
│ packed  │───>│ SwrContext │───>│ FLTP 浮点   │───>│ SwrContext │───>│ packed  │
│  PCM    │    │ (转FLTP)   │    │ biquad EQ   │    │ (转回packed)│   │  PCM    │
└─────────┘    └────────────┘    │ 级联处理    │    └────────────┘    └─────────┘
                                 └─────────────┘

处理流程:

  1. 格式转换:将输入 packed PCM 转为 FLTP 平面浮点格式
  2. EQ 滤波:直接在 FLTP 浮点数据上逐频段应用 biquad 滤波器
  3. 安全防护:清洗 NaN/Inf,防止数值发散
  4. 格式还原:将处理后的 FLTP 转回原始 packed 格式

关键代码

5 频段参数定义

qml 复制代码
// EqPage.qml - 频段参数列表
property ListModel bands: ListModel {
    ListElement { frequency: 60;    gain: 0.0; width: 1.0 }   // 低音(Bass)
    ListElement { frequency: 250;   gain: 0.0; width: 1.0 }   // 中低(Low Mid)
    ListElement { frequency: 1000;  gain: 0.0; width: 1.0 }   // 中音(Mid)
    ListElement { frequency: 4000;  gain: 0.0; width: 1.0 }   // 中高(High Mid)
    ListElement { frequency: 12000; gain: 0.0; width: 1.0 }   // 高音(Treble)
}

Packed PCM → FLTP 格式转换

cpp 复制代码
// Step 1: 准备 SwrContext(packed → FLTP)
SwrContext *swrIn = nullptr;
int ret = swr_alloc_set_opts2(&swrIn,
                               &layout, AV_SAMPLE_FMT_FLTP, sampleRate,
                               &layout, sampleSpec.format, sampleRate,
                               0, nullptr);
swr_init(swrIn);

// Step 2: 执行格式转换
const int convertedIn = swr_convert(swrIn, fltpPlanes, 
                                     static_cast<int>(inputFrameCount),
                                     inData, static_cast<int>(inputFrameCount));

Peaking EQ Biquad 核心实现

cpp 复制代码
// Step 3: 直接在 FLTP 浮点数据上应用 Peaking EQ 双二阶滤波器
for (int b = 0; b < bands.size(); ++b) {
    const double frequency = band.value("frequency").toDouble();
    const double gainDb = qBound(-24.0, band.value("gain").toDouble(), 24.0);
    const double Q = qMax(0.1, band.value("width", 1.0).toDouble());

    // ★ 关键:奈奎斯特频率保护
    // biquad 滤波器要求中心频率 < sampleRate/2,否则系数发散
    const double nyquist = sampleRate / 2.0;
    if (frequency <= 0.0 || frequency >= nyquist || qFuzzyIsNull(gainDb))
        continue;

    // 计算 biquad 系数(Audio Cookbook)
    const double A = std::pow(10.0, gainDb / 40.0);
    const double omega = 2.0 * M_PI * frequency / sampleRate;
    const double alpha = std::sin(omega) / (2.0 * Q);

    const double a0 =  1.0 + alpha / A;
    const double a1 = -2.0 * std::cos(omega);
    const double a2 =  1.0 - alpha / A;
    const double b0 =  1.0 + alpha * A;
    const double b1 = -2.0 * std::cos(omega);
    const double b2 =  1.0 - alpha * A;

    // 归一化系数
    const double nb0 = b0 / a0, nb1 = b1 / a0, nb2 = b2 / a0;
    const double na1 = a1 / a0, na2 = a2 / a0;

    // 对每个声道独立应用 Direct Form I 滤波
    for (int ch = 0; ch < channels; ++ch) {
        float *data = reinterpret_cast<float *>(fltpPlanes[ch]);
        double x1 = 0.0, x2 = 0.0, y1 = 0.0, y2 = 0.0;
        for (int n = 0; n < convertedIn; ++n) {
            const double x0 = static_cast<double>(data[n]);
            double y0 = nb0 * x0 + nb1 * x1 + nb2 * x2 
                      - na1 * y1 - na2 * y2;
            // NaN/Inf 保护:若滤波器发散则重置为原始输入
            if (std::isnan(y0) || std::isinf(y0))
                y0 = x0;
            // 防止级联滤波导致数值溢出
            y0 = qBound(-2.0, y0, 2.0);
            data[n] = static_cast<float>(y0);
            // 更新延迟线
            x2 = x1; x1 = x0;
            y2 = y1; y1 = y0;
        }
    }
}

数值安全防护

cpp 复制代码
// Step 3.5: 清洗 FLTP 数据,确保无 NaN/Inf(防止 resampler 失败)
for (int ch = 0; ch < channels; ++ch) {
    float *data = reinterpret_cast<float *>(fltpPlanes[ch]);
    for (int n = 0; n < convertedIn; ++n) {
        if (std::isnan(data[n]) || std::isinf(data[n]))
            data[n] = 0.0f;
    }
}

开发踩坑记录

踩坑一:firequalizer 滤镜图解析失败

FFmpeg 的 avfilter_graph_parse_ptr 按冒号拆分参数,gain 字符串内部的冒号被误认为参数分隔符:

复制代码
firequalizer=gain='f=60:g=0.0;f=250:g=3.0;...'
                                ^ 这里的冒号被当成参数分隔符

教训:对于包含复杂参数的 FFmpeg 滤镜,优先考虑程序化构建或直接数值计算。

踩坑二:超奈奎斯特频率导致输出畸变

当音频采样率为 16000 Hz 时,奈奎斯特频率为 8000 Hz。EQ 的高频段(12000 Hz)超过了奈奎斯特频率,biquad 滤波器系数发散(绝对值 > 1),输出指数级增长并被钳位为方波畸变。

解决方案:添加频率上限检查,自动跳过超出奈奎斯特频率的频段:

cpp 复制代码
const double nyquist = sampleRate / 2.0;
if (frequency <= 0.0 || frequency >= nyquist || qFuzzyIsNull(gainDb))
    continue;  // 跳过无效频段

踩坑三:级联滤波的数值累积

5 个频段依次滤波时,前一级的数值误差会被后续级放大。需要同时采取:

  • 系数安全检查:跳过 NaN/Inf 系数
  • 输出值钳位qBound(-2.0, y0, 2.0) 防止级联溢出
  • 最终 NaN 清洗:确保输出数据干净

预设模板

项目内置 5 种经典预设,涵盖常见音频风格需求:

预设名称 60Hz 250Hz 1kHz 4kHz 12kHz 适用场景
平坦 0 dB 0 dB 0 dB 0 dB 0 dB 参考基准
流行 +2 dB +1 dB +3 dB +2 dB +1 dB 增强中高频,人声清晰
摇滚 +5 dB +3 dB -1 dB +3 dB +5 dB 增强低频和高频
古典 -1 dB +1 dB +2 dB +1 dB +1 dB 增强动态范围
爵士 +2 dB +3 dB +1 dB +2 dB +3 dB 温暖饱满

总结

相比 FFmpeg firequalizer 滤镜方案,纯 C++ biquad 滤波器实现具有以下优势:

对比维度 firequalizer 滤镜 Peaking EQ biquad
兼容性 依赖 FFmpeg 版本 纯数学计算,无依赖
数值稳定性 由滤镜内部处理 完全可控,含防护机制
性能 滤镜图开销 直接内存操作,更高效
灵活性 受限于滤镜参数 完全自由定制
可调试性 黑盒 每一步都可追踪