ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

二阶IIR音频滤波器设计:巴特沃斯与双线性变换实战指南

二阶IIR音频滤波器设计:巴特沃斯与双线性变换实战指南

二阶IIR音频滤波器,听起来是个教科书名词,可我真正确掌握它的设计,是栽了跟头之后才做到的。之前在某项目里调低通滤波,我套用网上抄来的巴特沃斯系数模板直接烧进设备,实测截止点比预期低了将近10%,后来才明白问题出在数字域与模拟域的频率映射上。这篇文章想把“二阶IIR音频滤波器(基于巴特沃斯设计)”这件事从头到尾讲透:为什么巴特沃斯适合做音频、系数怎么从模拟原型推到z域、工程上用什么结构实现最省心,以及我在实际调试里踩过的坑和验证手段。适合正在写音频效果器、嵌入式音频代码,或者刚接触DSP但不想只看理论公式的开发者。

1. 巴特沃斯“最平坦”,这句广告语到底意味着什么

1.1 为什么是它:通带无纹波,转折处刚刚好

巴特沃斯滤波器最常被引用的特点是“最大平坦幅频响应”,意思是通带内幅度响应没有波纹,不会有某个频率点莫名被加强或削弱。对于音频来说这一点很关键,因为做EQ、分频、降噪时,我们希望滤波器做的事情是“该过的过、该减的减”,而不是在通带里留下可闻的染色。

二阶巴特沃斯的极点分布在s平面左半平面,一对共轭极点与虚轴呈45度角。正是这个几何位置决定了它的品质因数Q固定为:

Q = 1 / √2 ≈ 0.7071

这个Q值带来的结果是:在截止频率fc处,幅度精确衰减到-3dB(也就是0.707倍),没有凸起,也没有提前滚降。很多初学者会把Q理解成“越高越尖锐”,但在滤波器设计里,Q影响的是转折点附近的形状。Q低于0.707会让转折变得更圆缓,截止处衰减超过-3dB;Q高于0.707则会在截止处出现共振峰,比如Q=1时会有一个约1.25dB的凸起,听感上就是“某个频段有点顶耳朵”。

二阶巴特沃斯的滚降斜率固定为每倍频程-12dB。这是一个工程上很舒服的斜率:比一阶滤波器的-6dB/oct明显更有效,又不像四阶那样产生明显群延迟。在多数音频场景里,二阶巴特沃斯已经能把“需要滤掉的部分”压到人耳不太敏感的状态,而代价相对可控。

1.2 和切比雪夫、贝塞尔的取舍

有些场景下巴特沃斯不是唯一选择,甚至不是最优选择。我做选型时通常会拿三张牌对比:

逼近类型通带纹波过渡带陡度相位特性典型用途
巴特沃斯无纹波,最平坦中等(二阶12dB/oct)群延迟非线性,但无过冲音色塑形、分频、EQ基础
切比雪夫I型通带有纹波更陡群延迟波动更大需要更强截止的测量或抗混叠
贝塞尔无纹波最缓接近线性相位需要保形的脉冲信号

如果目标是“尽快滤掉隔壁频段的能量”,切比雪夫确实更猛,但通带纹波会在某些频率造成固定染色,音乐信号经过它之后容易产生“金属感”。贝塞尔赢在相位,代价是过渡带太缓,做EQ显得不够利落。巴特沃斯卡在中间:听感自然、无过冲、过渡带够用,所以音频领域最常见到它。我自己的习惯是:分频器、EQ、降噪链路优先用巴特沃斯;只有在测量器材校准这类对相位要求极高的地方才考虑贝塞尔;切比雪夫基本不碰。

2. 从模拟原型到数字系数:一张纸推导加工程公式

2.1 双线性变换的代价:频率压缩与预畸变

IIR数字滤波器最稳的设计路线,是先写出模拟原型,再用双线性变换映射到z域。模拟二阶巴特沃斯低通原型长这样:

H(s) = Ωa² / (s² + (Ωa/Q)s + Ωa²)

其中Ωa是预畸变后的模拟截止角频率,Q=0.7071。双线性变换的替代关系是:

s = 2Fs · (1 - z⁻¹) / (1 + z⁻¹)

这个变换有两个好处:第一,稳定的模拟左半平面极点会映射到z平面单位圆内,滤波器保持因果稳定;第二,它能把整个模拟频率轴压缩到数字的0到π频段,不会出现混叠。

代价是频率轴发生了非线性压缩。如果直接把想要的目标频率fc当作模拟Ωa代入,变换后数字滤波器的实际截止点会偏低,频率越高偏差越大。这就是开头我提到的翻车根源:没做预畸变,低通截止点整体往下跑。

解决办法是在代入原型前做预畸变(pre-warping):

Ωa = 2Fs · tan(π·fc / Fs)

fc是目标数字截止频率,单位Hz。这个公式会把希望的数字截止点精确钉在变换后的频率轴上,保证最终-3dB点就是你设置的fc。实际使用中,如果fc远小于Fs,tan近似等于线性值,偏差很小;一旦fc接近Fs/4以上,偏差会肉眼可见,必须老老实实预畸变。

2.2 用业界通用的biquad公式把系数一次算完

手动推完整个代入、合并、化简过程会很啰嗦。工程上我直接采用Audio EQ Cookbook里流传最广的那套biquad系数公式,它是把预畸变和双线性变换合并后的闭式解,省去中间推导。

对于二阶巴特沃斯低通,只需三个中间量:

w0 = 2π·fc / Fs
cos_w0 = cos(w0)
sin_w0 = sin(w0)
alpha = sin_w0 / (2Q)

其中Q固定取0.7071。然后系数直接写:

b0 = (1 - cos_w0) / 2
b1 = 1 - cos_w0
b2 = (1 - cos_w0) / 2
a0 = 1 + alpha
a1 = -2·cos_w0
a2 = 1 - alpha

最后所有系数除以a0,把差分方程变成:

y[n] = b0'·x[n] + b1'·x[n-1] + b2'·x[n-2] - a1'·y[n-1] - a2'·y[n-2]

分母归一化这步不能省,因为实际代码里我们只存b0、b1、b2、a1、a2五个系数,默认a0恒等于1。

2.3 一个数值例子算给你看

设Fs=48000Hz,目标fc=1000Hz,Q=0.7071。代入公式:

w0 = 2π×1000/48000 ≈ 0.130900 rad
cos(w0) ≈ 0.991445
sin(w0) ≈ 0.130526
alpha = 0.130526 / 1.414214 ≈ 0.092297
a0 = 1.092297

然后:

b0 ≈ 0.004278
b1 ≈ 0.008555
b2 ≈ 0.004278
a1 ≈ -1.982890
a2 ≈ 0.907703

归一化后:

b0' ≈ 0.003916
b1' ≈ 0.007833
b2' ≈ 0.003916
a1' ≈ -1.815349
a2' ≈ 0.831014

这组系数做出来的滤波器,1000Hz处增益约-3dB,10Hz到900Hz接近0dB,2000Hz处约-9dB,5000Hz处已经衰减到接近-18dB。如果手里有工具,可以拿SciPy的butter函数生成同参数系数对照,结果几乎一致,差异只在小数点后第5位左右,来自浮点舍入。

3. 系数落地:C代码模板与结构选型

3.1 TDF2实现与DF1的差别

有了系数,接下来是差分方程的具体实现结构。最直观的是Direct Form I(DF1),需要四个状态变量,分别存x[n-1]、x[n-2]、y[n-1]、y[n-2]。它逻辑清晰,适合教学,但内存多、中间变量多。实际音频代码里我更推荐Transposed Direct Form II(TDF2),只需要两个状态变量,代码更短,浮点下的噪声特性通常也更好。

TDF2的C语言实现如下:

#include <math.h> typedef struct { float b0, b1, b2; float a1, a2; float z1, z2; } biquad_t; void biquad_lp_butter_init(biquad_t *f, float fs, float fc) { float w0 = 2.0f * (float)M_PI * fc / fs; float cw = cosf(w0); float sw = sinf(w0); float alpha = sw * 0.70710678f; // sin(w0) / (2 * Q) float a0 = 1.0f + alpha; f->b0 = (1.0f - cw) * 0.5f / a0; f->b1 = (1.0f - cw) / a0; f->b2 = f->b0; f->a1 = (-2.0f * cw) / a0; f->a2 = (1.0f - alpha) / a0; f->z1 = 0.0f; f->z2 = 0.0f; } float biquad_process(biquad_t *f, float x) { float y = f->b0 * x + f->z1; f->z1 = f->b1 * x - f->a1 * y + f->z2; f->z2 = f->b2 * x - f->a2 * y; return y; }

注意TDF2里状态变量z1、z2的更新顺序:先算当前输出y,再用y更新z1,最后更新z2。这个顺序在绝大多数biquad实现里都是固定写法,不能随意换,否则输出会错位一个采样点。

DF1和TDF2在数学上完全等价,输出结果几乎一样。区别主要在数值表现:DF1有独立的延时线,输入输出历史值都比较“干净”,不容易出现内部累加溢出,但状态多;TDF2把部分累加藏在z1、z2里,当滤波器极点接近单位圆时,z1的内部数值可能远大于输入信号,在定点平台要格外小心。浮点处理器上我默认用TDF2;如果目标平台是没有硬件浮点的MCU,建议先评估内部定点溢出问题。

3.2 实际工程中如何调用与清状态

处理音频时按块循环调用就好:

for (int i = 0; i < num_samples; i++) { out[i] = biquad_process(&filter, in[i]); }

有一点容易被忽略:滤波器状态必须随着音频流生命周期正确清零。如果音频流中途断过、切换过场景,或者滤波器系数被大改,旧状态里残留的电平可能会产生一次瞬态爆音。我的做法是在每次音频设备启动、停止、参数大变时,把z1、z2显式置零。更稳妥的做法是配合一个极短的淡入淡出(比如5到10毫秒),把状态清零造成的咔哒声掩盖掉。

4. 一阶变三态:从低通衍生出高通和带通

4.1 高通:把分子翻个面

做高通只需要改分子系数,分母系数保持和低通完全一样。低通的分子零点在z=1处(直流被压零),高通则把零点放到z=-1处,让奈奎斯特频率被压零。公式如下:

类型b0b1b2a0a1a2
低通(1-cos_w0)/21-cos_w0(1-cos_w0)/21+alpha-2cos_w01-alpha
高通(1+cos_w0)/2-(1+cos_w0)(1+cos_w0)/21+alpha-2cos_w01-alpha
带通alpha0-alpha1+alpha-2cos_w01-alpha

带通这里的alpha由中心频率和带宽决定,Q_bp = fc/BW,alpha = sin(w0)/(2Q_bp)。带通不再严格是巴特沃斯,因为巴特沃斯本质上描述的是低通/高通的“最平坦”属性;拿通用biquad公式做带通会更灵活,日常够用。

高通在音频里的典型用法是切除低频隆隆声、去除直流偏置。比如录音里常见的空调低频噪声、麦克风近讲效应带来的低频提升,用高通100Hz左右处理效果非常自然。

4.2 高阶怎么做:级联而不是重算

需要更陡峭的滚降时,正确的做法不是去推导“四阶巴特沃斯闭式解”,而是把两个完全相同的二阶巴特沃斯节级联起来。二级级联后滚降变成-24dB/oct,这就是Linkwitz-Riley分频器的核心思路——很多专业音箱的分频网络就是两只相同巴特沃斯二阶串起来。级联实现简单,代码里再建一个biquad_t实例,按顺序处理即可:

y1[i] = biquad_process(&stage1, x[i]);
y2[i] = biquad_process(&stage2, y1[i]);

级联时要注意headroom:第一级输出可能在某些频段接近满幅,第二级再叠加增益,整体容易过载。我会在链路里预留至少6dB余量,或者在每一级后面加一个纯增益做归一化。四阶巴特沃斯虽然在频域更陡,但群延迟明显变大,对脉冲型信号会产生更长的“拖尾”,做实时监听要留意。

5. 数字域里最容易翻车的三件事:系数精度、极点贴边、采样率误配

5.1 采样率不同步导致截止偏移

这是最隐蔽的坑。如果代码里写死了Fs=48000,但底层音频驱动实际工作在其他采样率,所有频率点都会整体偏移。估算方法很简单:实际截止频率约等于目标fc乘以两个采样率的比值。假设代码按48000算系数,实际跑在44100,那么1000Hz的截止会变成1000×44100/48000≈919Hz,整体下移近8%。反之代码按44100算、实际跑48000,截止会抬高到1088Hz。

遇到这个问题的第一反应不是去猜,而是先从框架里把真实采样率打出来,确认参数一致。很多音频中间件在初始化时会修改时钟配置,和外部声卡之间也可能存在取样率转换,光看工程配置不可靠。为了自检,我习惯在初始化后打印w0或直接打印归一化系数,对照预期值一目了然。

5.2 小截止频率下float精度不够用

当fc特别低、Fs又特别高时,比如Fs=48000、fc=40Hz,w0只有0.0052,sin(w0)约等于0.0052,alpha只有0.0037左右,a2≈0.9963。这时极点非常靠近单位圆,滤波器内部状态z1和z2在低频信号通过时会累积特别大的值。这套系数用float算,理论误差不大,但在24bit定点或老平台上是灾难,输出底噪会明显抬高,甚至出现低频自激的“嗡”声。

我踩过这个坑之后总结了两条规矩:第一,系数计算统一用double,算完再转float给运行结构体,这能避免初始化阶段的精度损失;第二,极低截止频率尽量放在高采样率链路的中间,别让它在44.1kHz这种本来就紧张的带宽里硬扛。如果一定要在低频段做处理,先做一次96kHz过采样,让滤波器在更宽的频率间隔里工作,系数会更健康。

5.3 实时调参爆音:系数插值怎么做

一边播放音乐一边拖动截止频率参数,如果直接给滤波器赋一组新系数,输出会瞬间跳变,听到的就是“咔哒”一声。原因是z1、z2里的旧状态仍然基于旧系数积累,新系数突然接管后,差分方程的平衡点被打破。

最简单的缓解方案是系数缓动:每次只把系数向目标值移动一小步,用几十毫秒完成过渡。比如每采样更新:

f->a1 += (target_a1 - f->a1) * 0.0005f; f->a2 += (target_a2 - f->a2) * 0.0005f; // b0, b1, b2 同理

但系数插值在TDF2结构下有一个隐患:状态变量z1、z2不会跟随系数插值连续变化,插值过程也可能产生轻微杂音。更彻底的做法是同时保持“旧系数滤波器和新系数滤波器并行”,输出在几十毫秒内从旧滤波器渐变到新滤波器,这个就叫交叉淡化。工程量大一些,但爆音问题彻底消失。我自己的系统里,如果参数变化不频繁,用系数插值就够;频繁自动扫频(比如做Wah效果)时,我会用交叉淡化。

6. 验证闭环:脉冲、扫频与听感三方对照

6.1 脉冲响应验证系数

验证系数有没有算错,最快的方法是用单位脉冲输入。输入x=[1,0,0,0,...],根据已归一化的差分方程,前几个输出可以直接手算:

y[0] = b0
y[1] = b1 - a1·y0
y[2] = b2 - a1·y1 - a2·y0

对于上面的1000Hz低通系数,算出来y0≈0.003916,y1≈0.015,y2≈0.037。把这些预期值写进自动化测试,跑代码后对比实际输出,能立刻发现系数赋值顺序、符号正负、甚至状态变量未清零的问题。这招比直接听声音可靠得多,因为人耳对毫秒级的错误不敏感。

6.2 扫频与白噪声实测

脉冲验证通过后,再看频域。我惯用的方法是扫正弦:生成不同频率的单音,分别过滤波器,测稳态RMS增益。用Python写一个很短的脚本:

import numpy as np from scipy.signal import lfilter fs = 48000 fc = 1000 b = [0.003916, 0.007833, 0.003916] a = [1.0, -1.815349, 0.831014] def gain_at(freq, seconds=0.5): t = np.arange(0, seconds, 1 / fs) x = np.sin(2 * np.pi * freq * t) y = lfilter(b, a, x) idx = int(fs * 0.2) # 丢弃前0.2秒瞬态 return np.sqrt(np.mean(y[idx:] ** 2)) / np.sqrt(0.5) for f in [100, 500, 1000, 2000, 5000]: print(f, round(gain_at(f), 3))

预期输出里,100Hz接近1.0,1000Hz大约0.707,2000Hz大概0.35,5000Hz不会超过0.15。如果1000Hz处的值明显偏离0.707,优先检查预畸变或Q值是否写成了1。白噪声验证也类似,但白噪声结果方差大,更适合看整体频谱形状,不适合精确读数。

6.3 听感主观检验的量化方法

最后是人耳验收,但别只凭“感觉”。低通1kHz处理后,高频嘶声、齿音应该明显收敛,鼓点变得更“闷”更有肌肉感;高通100Hz处理后,低频隆隆声消失,人声变薄但更干净。为了减少主观偏差,我会把原始素材和滤波输出做AB切换,同时用RMS表观察整体电平变化。滤波器通带增益接近1,输出RMS应该和输入接近,如果出现明显音量下降,说明系数归一化出了问题,比如b0算错。

做分频点验证时还要看群延迟:让一个瞬态鼓点通过低通和高通再混合,听感上应该保持时间对齐。如果高音和低音错位,后处理链路里一定还有未补偿的延迟。巴特沃斯本身群延迟在截止点附近最大,两级级联后更明显,这属于物理规律而不是实现错误,但调试时要心里有数。

最后分享一个我后来一直用的技巧:设计完滤波器,不要只看幅频响应,一定要看阶跃响应。给滤波器输入一列常数值,观察输出如何爬升到稳态。巴特沃斯无过冲,但低截止时建立时间会变长,如果系统快速切换参数时每次都能听到“呼哧”一声尾音,多半就是状态没有清理干净或者参数跳变太快。阶跃响应能把这类问题直接暴露在示波器上,比反复调系数高效得多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进