Metlivi 博客

如何构建带有独立带宽和中心频率控制的带阻滤波器

构建一个包含两个参数的数字带阻滤波器:以赫兹为单位的中心频率和以赫兹为单位的阻带带宽。任一参数发生变化时,都要重新计算滤波器系数。如果你的滤波器库接受的是 Q 值而非带宽,请在每次更新时计算 Q = center / width,这样移动中心频率时仍能保持指定的带宽。

本教程将仅使用标准库构建一个可运行的 Python 音频原型。它会生成测试信号、对其进行处理、验证阻带带宽,并可选择导出 WAV 文件供试听。控制项是命令行参数;之后可以让同一个系数函数连接到音频应用中的两个旋钮。

2026年10月29日阅读约需 9 分钟作者:Metlivi Editorial Team
第 1 节

这两个控制项实际应该改变什么?

中心频率控制项设定衰减最大的频率。带宽控制项设定两个半功率边界之间的距离,这两个边界通常称为 −3 dB 点。陷波器是一种窄带形式的带阻滤波器;SciPy 的官方文档将其品质因数定义为中心频率除以此带宽。SciPy:iirnotch

设计控制项前,先确定带宽的单位。以赫兹表示的带宽保持固定的绝对频率跨度;以倍频程表示的带宽描述的是频率比。W3C 的 Audio EQ Cookbook 明确支持陷波滤波器的倍频程带宽,并在其数字系数公式中考虑了频率扭曲。这两种控制行为不同,因此应清楚标明所选单位。W3C:Audio EQ Cookbook

本示例使用赫兹。假设中心频率为 1,000 Hz,带宽为 200 Hz:对应的 Q 值为 5。将中心频率移到 2,000 Hz,同时保持 200 Hz 带宽,Q 值就必须变为 10。如果 Q 值保持为 5,带宽则会变成 400 Hz。这些示例计算说明了为什么固定 Q 值的控制不符合我们选定的任务。

第 2 节

哪种滤波器结构能提供独立控制?

使用一个二阶无限脉冲响应滤波器,也称为双二阶滤波器。它有三个分子系数和三个分母系数。将第一个分母系数归一化为 1 后,其采样方程为:\n\n\[\ny[n]=b_0x[n]+b_1x[n-1]+b_2x[n-2]-a_1y[n-1]-a_2y[n-2].\n\]\n\n这是 Audio EQ Cookbook 中记录的双二阶滤波器关系式。W3C:Audio EQ Cookbook\n\n对于采样率 \(F_s\)、中心频率 \(f_c\) 和带宽 \(B\),计算:\n\n\[\n\omega_c=2\pi f_c/F_s,\qquad\n\beta=\tan(\pi B/F_s),\qquad\ng=\frac{1}{1+\beta}.\n\]\n\n然后使用:\n\n\[\nb=g[1,-2\cos(\omega_c),1],\n\]\n\n\[\na=[1,-2g\cos(\omega_c),2g-1].\n\]\n\n这些是 SciPy 实现中使用的陷波系数方程,这里改写为直接输入以赫兹为单位的带宽。SciPy 滤波器设计源码\n\n两个控制项都可能改变多个系数。它们的独立性体现在最终响应上:改变带宽时,衰减频率保持不变;改变中心频率时,指定带宽保持不变。将每个旋钮仅对应到一个系数,并不能实现这种行为。

第 3 节

如何构建可运行的音频原型?

将以下完整程序保存为 band_reject.py。它需要 Python 3.8 或更高版本,无需安装其他软件包。采样率为 48,000 Hz,生成的输入信号包含 300、1,000 和 5,000 Hz 三个音调,每个音调的振幅均为 0.2。

程序直接计算滤波器响应,通过二分法查找两个半功率边界,并处理两秒钟的生成音频。WAV 导出使用 Python 文档中的 wave 接口写入单声道、16 位 PCM 文件。如果输出文件已存在,程序会报错,而不会覆盖文件。Python:wave — 读取和写入 WAV 文件

import argparse
import cmath
import math
import struct
import wave


def coefficients(center, width, fs):
    if not all(math.isfinite(v) for v in (center, width, fs)):
        raise ValueError('Parameters must be finite')
    if not (fs > 0 and 0 < center < fs / 2 and 0 < width < fs / 2):
        raise ValueError('Require fs > 0 and center, width inside (0, fs/2)')
    beta = math.tan(math.pi * width / fs)
    g = 1 / (1 + beta)
    c = math.cos(2 * math.pi * center / fs)
    return (g, -2 * g * c, g), (1.0, -2 * g * c, 2 * g - 1)


def magnitude(f, b, a, fs):
    z = cmath.exp(-2j * math.pi * f / fs)
    return abs((b[0] + b[1]*z + b[2]*z*z) /
               (a[0] + a[1]*z + a[2]*z*z))


def edge(lo, hi, b, a, fs):
    target = 1 / math.sqrt(2)
    for _ in range(60):
        mid = (lo + hi) / 2
        if ((magnitude(lo, b, a, fs) - target) *
                (magnitude(mid, b, a, fs) - target)) > 0:
            lo = mid
        else:
            hi = mid
    return (lo + hi) / 2


def process(samples, b, a):
    x1 = x2 = y1 = y2 = 0.0
    result = []
    for x in samples:
        y = b[0]*x + b[1]*x1 + b[2]*x2 - a[1]*y1 - a[2]*y2
        result.append(y)
        x2, x1, y2, y1 = x1, x, y1, y
    return result


def write_wav(path, samples, fs):
    if any(not math.isfinite(x) or abs(x) > 1 for x in samples):
        raise ValueError('Output exceeds PCM range; lower the input level')
    data = b''.join(struct.pack('<h', round(x * 32767)) for x in samples)
    with open(path, 'xb') as stream:
        with wave.open(stream, 'wb') as wav:
            wav.setnchannels(1)
            wav.setsampwidth(2)
            wav.setframerate(fs)
            wav.writeframes(data)


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument('--center', type=float, default=1000)
    parser.add_argument('--width', type=float, default=200)
    parser.add_argument('--write', action='store_true')
    args = parser.parse_args()
    fs = 48000
    b, a = coefficients(args.center, args.width, fs)
    low = edge(0, args.center, b, a, fs)
    high = edge(args.center, fs / 2, b, a, fs)
    print(f'Edges: {low:.3f}, {high:.3f} Hz; width: {high-low:.3f} Hz')
    assert math.isclose(high-low, args.width, abs_tol=1e-5)
    assert magnitude(args.center, b, a, fs) < 1e-7
    tones = (300, 1000, 5000)
    for f in tones:
        db = 20 * math.log10(max(magnitude(f, b, a, fs), 1e-12))
        print(f'{f} Hz: {db:.2f} dB')
    samples = [sum(0.2 * math.sin(2*math.pi*f*n/fs) for f in tones)
               for n in range(2*fs)]
    filtered = process(samples, b, a)
    assert len(filtered) == len(samples)
    assert all(math.isfinite(y) for y in filtered)
    if args.write:
        write_wav('input-demo.wav', samples, fs)
        write_wav('filtered-demo.wav', filtered, fs)


if __name__ == '__main__':
    main()

运行 python band_reject.py --center 1000 --width 200 检查响应。添加 --write 可创建 input-demo.wav 和 filtered-demo.wav。将这些文件导入音频编辑器的不同轨道,起始位置对齐,并在播放音量不变的情况下进行比较。

我运行了完整程序,但没有导出文件。带宽、中心频率衰减、输出长度和输出为有限值的断言均通过。本次运行未测试 WAV 写入。打印的响应在 300 Hz 处约为 −0.02 dB,在 5,000 Hz 处约为 −0.01 dB;中心频率处的值达到了显示下限 −240 dB。这个下限是数值报告的限制,并非实测的音频文件衰减深度。

第 4 节

如何验证两个控制项彼此独立?

测试三种设置:基准设置、保持带宽不变而改变中心频率的设置,以及保持中心频率不变而改变带宽的设置。这样可以分别检验每个控制项。以下数值是在 48 kHz 下运行系数计算和边界搜索程序得出的;它们描述的是数学响应,而非录音测量结果。

中心频率设置 带宽设置 下半功率边界 上半功率边界 计算跨度
1,000 Hz 200 Hz 904.959 Hz 1,104.959 Hz 200.000 Hz
2,000 Hz 200 Hz 1,902.441 Hz 2,102.441 Hz 200.000 Hz
1,000 Hz 400 Hz 819.692 Hz 1,219.692 Hz 400.000 Hz

请注意,边界并不完全等于 center ± width/2。这里的“中心”指衰减零点,而“带宽”指两个半功率交点之间的间隔。计算出的响应表明,这两个交点的算术中点不一定等于零点频率。不要仅通过对中心频率加减半个带宽来显示边界。

如果想在安装了 SciPy 的环境中再做一次数值检查,可以使用 scipy.signal.freqz 评估返回的系数。提供 fs 和明确的频率值,包括精确的中心频率。文档说明,显式频率数组可以指定响应的计算位置;较粗的默认网格可能会错过狭窄的零点。SciPy:freqz

第 5 节

如何为声音选择中心频率和带宽?

使用你正在编辑的声音中的一段简短、可重复的片段。先找出想要削弱的频率区域,再将中心频率设在那里。从较窄的带宽开始,逐渐增大,直到覆盖目标区域。每次调整后都与旁路状态比较,并保持播放音量一致,确保比较有效。

使用以下判断表选择下一步调整:

比较结果 下一步调整
不需要的音调仍然存在,但附近的内容发生了变化 在加宽前重新检查中心频率
中心频率正确,但不需要的区域超出了范围 逐步增大带宽
周围的音色损失过多 减小带宽并再次比较
需要削弱多个彼此分离的音调 在这些频率分别使用独立的陷波段
需要一个宽而平坦的衰减区域 重新考虑滤波器形状和阶数

这些是实际调整建议,并非来自某段特定录音的测量结果。三音调示例刻意设计得很简单:它让你先确认处理器工作正常,再对更复杂的声音做出判断。

单个陷波器也无法提供独立可调的衰减深度。Cookbook 为陷波和峰值均衡器滤波器分别提供了不同的系数组。如果你的任务需要的是适度且可调的削减,而非衰减零点,可以进一步研究峰值均衡器这一合适的设计。W3C:Audio EQ Cookbook

第 6 节

连接两个实时旋钮后,哪些方面会改变?

保持相同的参数约定:center_hz 和 width_hz 一起传入系数函数。任一参数变化时,都要更新完整的系数集;处理采样率变化时也要重新计算。对于此原型,两个控制值都必须严格大于零且小于采样率的一半;无效设置会触发异常。

提供的 process() 函数处理完整的单声道缓冲区,并在每次调用时将历史状态初始化为零。流式版本必须在不同数据块之间保留延迟历史状态。SciPy 的 lfilter 文档说明了如何为此使用初始延迟状态和返回的最终状态。不同音频声道要分别维护历史状态。SciPy:lfilter

将旋钮移动视为额外的实现步骤。此示例验证固定设置;它没有实现连续自动化。一种实用的切换策略是短暂并行运行旧滤波器和新滤波器,让新滤波器建立历史状态,然后对两者的输出进行交叉淡化。应使用实际素材和预期的控制速度评估切换效果。

串联多个滤波器时,应将每个滤波器保留为独立的二阶节,而不是将级联展开为一个高阶多项式。SciPy 建议大多数滤波任务使用二阶节,因为它们的数值问题较少。SciPy:lfilter

第 7 节

应优先检查哪些故障情况?

如果陷波出现在错误的频率,请检查计算系数时使用的采样率是否与音频的实际采样率一致。如果移动中心频率时带宽增大,请检查封装代码是否将 Q 值保持不变。如果响应图显示的衰减较浅,请先计算精确中心频率处的响应,再更改设计。

如果重复处理缓冲区时出现不连续,请检查状态处理:演示程序会在每次调用 process() 时重置历史状态。如果 WAV 导出触发范围错误,请降低输入振幅后重试;程序会有意拒绝静默削波超出范围的样本。要再次导出一对文件,必须先重命名或删除现有的演示文件。

当中心频率测试能将零点放在指定位置、两个交点之间的距离保持为指定带宽,并且处理后的声音满足预期编辑效果时,这个构建就完成了。从离线原型转向交互式音频工具时,应将这些检查与两个控制项一同保留。

第 8 节

来源

相关阅读

继续探索这个主题