Metlivi Blog

How to Build a Band-Reject Filter with Separate Width and Center Controls

Build a digital band-reject filter with two parameters: center frequency in hertz and rejection bandwidth in hertz. Recalculate the filter coefficients whenever either changes. If your filter library accepts Q instead of bandwidth, calculate Q = center / width on every update so moving the center preserves the requested width.

This tutorial builds a working Python audio prototype using only the standard library. It generates a test signal, processes it, verifies the rejection bandwidth, and optionally exports WAV files for listening. The controls are command-line parameters; the same coefficient function can later sit behind two knobs in an audio application.

October 29, 20269 min readBy Metlivi Editorial Team
Section 1

What Should the Two Controls Actually Change?

The center control sets the frequency of maximum rejection. The width control sets the distance between the two half-power boundaries, conventionally called the −3 dB points. A notch is a narrow-band form of a band-stop filter; SciPy’s official documentation defines its quality factor as center frequency divided by this bandwidth. SciPy: iirnotch

Choose the width unit before designing the controls. A width expressed in hertz preserves an absolute frequency span. A width expressed in octaves describes a frequency ratio. W3C’s Audio EQ Cookbook explicitly supports octave bandwidth for notch filters and accounts for frequency warping in its digital coefficient formulas. These are different control behaviors, so label the chosen unit clearly. W3C: Audio EQ Cookbook

For this build, use hertz. Suppose the center is 1,000 Hz and the width is 200 Hz: the corresponding Q is 5. Moving the center to 2,000 Hz while retaining a 200 Hz width requires Q to become 10. Holding Q at 5 would instead produce a 400 Hz bandwidth. These illustrative calculations explain why a fixed-Q control does not satisfy our chosen task.

Section 2

Which Filter Structure Gives You Separate Controls?

Use one second-order infinite impulse response filter, also called a biquad. It has three numerator coefficients and three denominator coefficients. After normalizing the first denominator coefficient to one, its sample equation is:

[ y[n]=b_0x[n]+b_1x[n-1]+b_2x[n-2]-a_1y[n-1]-a_2y[n-2]. ]

This is the biquad relationship documented in the Audio EQ Cookbook. W3C: Audio EQ Cookbook

For a sample rate (F_s), center (f_c), and width (B), calculate:

[ \omega_c=2\pi f_c/F_s,\qquad \beta=\tan(\pi B/F_s),\qquad g=\frac{1}{1+\beta}. ]

Then use:

[ b=g[1,-2\cos(\omega_c),1], ]

[ a=[1,-2g\cos(\omega_c),2g-1]. ]

These are the notch coefficient equations used in SciPy’s implementation, rewritten with bandwidth in hertz as the direct input. SciPy filter-design source

Both controls may change several coefficients. Their independence concerns the resulting response: changing width keeps the rejection frequency fixed, while changing center keeps the specified bandwidth fixed. Assigning each knob to just one coefficient would not establish that behavior.

Section 3

How Do You Build a Working Audio Prototype?

Save the following complete program as band_reject.py. It needs Python 3.8 or later and no additional packages. The sample rate is 48,000 Hz, and the generated input contains three tones at 300, 1,000, and 5,000 Hz. Each tone has an amplitude of 0.2.

The program evaluates the filter response directly, locates both half-power boundaries by bisection, and processes two seconds of generated audio. Its WAV export uses Python’s documented wave interface to write mono, 16-bit PCM files. Existing output files cause an error rather than being overwritten. Python: wave — Read and write WAV files

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()

Run python band_reject.py --center 1000 --width 200 to check the response. Add --write to create input-demo.wav and filtered-demo.wav. Import those files into an audio editor on separate tracks, align their starts, and compare them at unchanged playback gain.

I executed the complete program without file export. Its bandwidth, center-rejection, output-length, and finite-output assertions passed. WAV writing was not exercised in that run. The printed response was approximately −0.02 dB at 300 Hz and −0.01 dB at 5,000 Hz; the center value reached the display floor of −240 dB. That floor is a numerical reporting limit, not a measured audio-file rejection depth.

Section 4

How Can You Verify That the Controls Are Independent?

Test three settings: a baseline, a changed center with unchanged width, and a changed width with unchanged center. This isolates each control. The following values were calculated by executing the coefficient and boundary-search routines at 48 kHz; they describe the mathematical response, not a recording measurement.

Center setting Width setting Lower half-power boundary Upper half-power boundary Calculated span
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

Notice that the boundaries are not exactly center ± width/2. Here, “center” identifies the rejection null, while “width” identifies the separation between the half-power crossings. The calculated response demonstrates that their arithmetic midpoint need not equal the null frequency. Do not build a boundary display by simply adding and subtracting half the width.

For an additional numerical check in a SciPy-equipped environment, evaluate the returned coefficients with scipy.signal.freqz. Supply fs and explicit frequencies, including the exact center. The documentation explains that an explicit frequency array controls where the response is evaluated; a coarse default grid can miss a narrow null. SciPy: freqz

Section 5

How Should You Choose Center and Width for a Sound?

Use a short, repeatable passage from the sound you are editing. First identify the frequency region you want to reduce, then set the center there. Begin with a narrow width and increase it until the reduction covers the intended region. Compare against bypass after each change, keeping playback gain consistent so the comparison remains useful.

Use this decision aid to choose the next adjustment:

What the comparison reveals Next adjustment
The unwanted tone remains, but nearby content changes Recheck the center before widening
The center is right, but the unwanted region extends beyond it Increase width gradually
Too much surrounding timbre disappears Reduce width and compare again
Several separated tones need reduction Evaluate separate notch sections at those frequencies
A broad, flat rejected region is required Reconsider the filter shape and order

These are practical adjustment suggestions, not measurements from a particular recording. The three-tone example is deliberately simple: it lets you establish that the processor behaves correctly before making judgments about a more complex sound.

A single notch also does not provide an independently adjustable depth control. The cookbook gives separate coefficient families for notch and peaking EQ filters. If your task requires a moderate, adjustable cut rather than a rejection null, a peaking EQ is another appropriate design to investigate. W3C: Audio EQ Cookbook

Section 6

What Changes When You Connect Two Live Knobs?

Keep the same parameter contract: center_hz and width_hz enter the coefficient function together. Update the complete coefficient set whenever either parameter changes, and recalculate when the processing sample rate changes. For this prototype, both controls must remain strictly above zero and below half the sample rate; invalid settings raise an exception.

The supplied process() function handles a complete mono buffer and begins with zero history on each call. A streaming version must retain its delay history between blocks. SciPy’s lfilter documents initial delay state and returned final state for this purpose. Maintain separate histories for separate audio channels. SciPy: lfilter

Treat moving controls as an additional implementation step. This example verifies fixed settings; it does not implement continuous automation. One practical transition strategy is to run the old and new fixed filters in parallel briefly, allow the new filter to establish its history, and crossfade their outputs. Assess the transition with the actual material and intended control speed.

For several filters in series, retain each as a separate second-order section rather than expanding the cascade into one high-order polynomial. SciPy recommends second-order sections for most filtering tasks because they have fewer numerical problems. SciPy: lfilter

Section 7

Which Failure Conditions Should You Check First?

If the notch appears at the wrong frequency, check the sample rate used for coefficient calculation against the audio’s actual sample rate. If width grows when you move the center, check whether your wrapper holds Q constant. If the response display looks shallow, evaluate the exact center before changing the design.

If repeated buffers produce discontinuities, inspect state handling: the demonstration resets history each time process() is called. If WAV export raises a range error, lower the input amplitude and rerun; the program intentionally refuses to silently clip out-of-range samples. Renaming or removing existing demonstration files is necessary before exporting another pair.

The build is complete when the center test places the null where requested, the two boundary crossings remain the requested distance apart, and the processed sound supports the intended edit. Keep those checks alongside the two controls as you move from this offline prototype to an interactive audio tool.

Section 8

Sources

Related reading

Keep exploring this topic