如何构建带有独立带宽和中心频率控制的带阻滤波器
构建一个包含两个参数的数字带阻滤波器:以赫兹为单位的中心频率和以赫兹为单位的阻带带宽。任一参数发生变化时,都要重新计算滤波器系数。如果你的滤波器库接受的是 Q 值而非带宽,请在每次更新时计算 Q = center / width,这样移动中心频率时仍能保持指定的带宽。
本教程将仅使用标准库构建一个可运行的 Python 音频原型。它会生成测试信号、对其进行处理、验证阻带带宽,并可选择导出 WAV 文件供试听。控制项是命令行参数;之后可以让同一个系数函数连接到音频应用中的两个旋钮。
这两个控制项实际应该改变什么?
中心频率控制项设定衰减最大的频率。带宽控制项设定两个半功率边界之间的距离,这两个边界通常称为 −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 值的控制不符合我们选定的任务。
哪种滤波器结构能提供独立控制?
使用一个二阶无限脉冲响应滤波器,也称为双二阶滤波器。它有三个分子系数和三个分母系数。将第一个分母系数归一化为 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两个控制项都可能改变多个系数。它们的独立性体现在最终响应上:改变带宽时,衰减频率保持不变;改变中心频率时,指定带宽保持不变。将每个旋钮仅对应到一个系数,并不能实现这种行为。
如何构建可运行的音频原型?
将以下完整程序保存为 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。这个下限是数值报告的限制,并非实测的音频文件衰减深度。
如何验证两个控制项彼此独立?
测试三种设置:基准设置、保持带宽不变而改变中心频率的设置,以及保持中心频率不变而改变带宽的设置。这样可以分别检验每个控制项。以下数值是在 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
如何为声音选择中心频率和带宽?
使用你正在编辑的声音中的一段简短、可重复的片段。先找出想要削弱的频率区域,再将中心频率设在那里。从较窄的带宽开始,逐渐增大,直到覆盖目标区域。每次调整后都与旁路状态比较,并保持播放音量一致,确保比较有效。
使用以下判断表选择下一步调整:
| 比较结果 | 下一步调整 |
|---|---|
| 不需要的音调仍然存在,但附近的内容发生了变化 | 在加宽前重新检查中心频率 |
| 中心频率正确,但不需要的区域超出了范围 | 逐步增大带宽 |
| 周围的音色损失过多 | 减小带宽并再次比较 |
| 需要削弱多个彼此分离的音调 | 在这些频率分别使用独立的陷波段 |
| 需要一个宽而平坦的衰减区域 | 重新考虑滤波器形状和阶数 |
这些是实际调整建议,并非来自某段特定录音的测量结果。三音调示例刻意设计得很简单:它让你先确认处理器工作正常,再对更复杂的声音做出判断。
单个陷波器也无法提供独立可调的衰减深度。Cookbook 为陷波和峰值均衡器滤波器分别提供了不同的系数组。如果你的任务需要的是适度且可调的削减,而非衰减零点,可以进一步研究峰值均衡器这一合适的设计。W3C:Audio EQ Cookbook
连接两个实时旋钮后,哪些方面会改变?
保持相同的参数约定:center_hz 和 width_hz 一起传入系数函数。任一参数变化时,都要更新完整的系数集;处理采样率变化时也要重新计算。对于此原型,两个控制值都必须严格大于零且小于采样率的一半;无效设置会触发异常。
提供的 process() 函数处理完整的单声道缓冲区,并在每次调用时将历史状态初始化为零。流式版本必须在不同数据块之间保留延迟历史状态。SciPy 的 lfilter 文档说明了如何为此使用初始延迟状态和返回的最终状态。不同音频声道要分别维护历史状态。SciPy:lfilter
将旋钮移动视为额外的实现步骤。此示例验证固定设置;它没有实现连续自动化。一种实用的切换策略是短暂并行运行旧滤波器和新滤波器,让新滤波器建立历史状态,然后对两者的输出进行交叉淡化。应使用实际素材和预期的控制速度评估切换效果。
串联多个滤波器时,应将每个滤波器保留为独立的二阶节,而不是将级联展开为一个高阶多项式。SciPy 建议大多数滤波任务使用二阶节,因为它们的数值问题较少。SciPy:lfilter
应优先检查哪些故障情况?
如果陷波出现在错误的频率,请检查计算系数时使用的采样率是否与音频的实际采样率一致。如果移动中心频率时带宽增大,请检查封装代码是否将 Q 值保持不变。如果响应图显示的衰减较浅,请先计算精确中心频率处的响应,再更改设计。
如果重复处理缓冲区时出现不连续,请检查状态处理:演示程序会在每次调用 process() 时重置历史状态。如果 WAV 导出触发范围错误,请降低输入振幅后重试;程序会有意拒绝静默削波超出范围的样本。要再次导出一对文件,必须先重命名或删除现有的演示文件。
当中心频率测试能将零点放在指定位置、两个交点之间的距离保持为指定带宽,并且处理后的声音满足预期编辑效果时,这个构建就完成了。从离线原型转向交互式音频工具时,应将这些检查与两个控制项一同保留。
