10.3. Filter Design and Analysis#

In the previous section, we saw first how the frequency domain view of convolutional filters lets us reason about their effects. We then saw that the ideal low-pass filter produced some less-than-ideal results when applied to real signals, notably ringing artifacts which arise from using finite-length approximations to the ideal filter.

In this section, we’ll develop this idea further, and see how to construct better convolutional filters.

10.3.1. Terminology#

Before we go further into filter design, it will help to establish some terminology.

For a low-pass filter with cutoff \(f_c\), the pass band is the set of frequencies \(0 \leq f \leq f_c\) that pass through the filter.

The set of frequencies which are blocked by the filter is referred to as the stop band.

In general, there is a region between the pass and stop bands where frequencies are attenuated, but still audible, which is known as the transition band.

These regions are illustrated below for an example filter in Fig. 10.5.

Illustration of a filter's frequency response with different regions annotated

Fig. 10.5 The frequency response plot of a filter \(h\) is the magnitude of its DFT \(H\). This filter was designed to have a cutoff of \(f_c = 6000\) Hz and a transition band of \(300\) Hz.#

Within the pass-band, a filter may not have perfectly flat response. The amount of variation within the pass-band (max-min) is known as pass-band ripple. An ideal filter would have 0 pass-band ripple, which amounts to no change in amplitude for passing frequencies.

Similarly, the difference from the peak response of the pass-band to the peak of the stop-band is the stop-band attenuation. This measures how audible the presumably stopped frequencies will be in the output. An ideal filter would have infinite attenuation, but the filter in Fig. 10.5 has only about 16dB, which is well within the range of human hearing. (This is probably not a good filter.)

10.3.1.1. What makes a good filter?#

The diagram above gives us a way to compare different filters. In general, the desirable properties of a filter are:

  1. High stop-band attenuation. The filter stops frequencies when it should.

  2. Small pass-band ripple. Passing frequencies aren’t too distorted.

  3. Narrow transition band. The filter efficiently moves from pass to stop behavior.

These three properties often present a trade-off, and can depend on the order of the filter in complicated ways. Designing filters isn’t easy, but this section will give you some pointers to standard methods.

10.3.2. The window method#

One of the most common approaches to making low-pass filters is known as the window method, and it works by applying a window to the impulse response of the ideal low-pass filter. This is similar to the approach taken earlier to combat spectral leakage, though for a slightly different reason here. Rather than force the filter to be periodic, windowing here simply forces the filter to depend only on a finite number of samples, and reduces ringing artifacts.

The window method proceeds by first determining the order \(K\) of the filter, which is typically an integer \(p\) multiple of the cutoff frequency \(f_c\) (measured in samples):

\[K = \left\lfloor\frac{p \cdot f_s}{f_c}\right\rfloor \quad \left[\text{samples}\right]\]

Next, an ideal LPF is constructed for length \(K\) and its impulse response \(\blue{h}\) is computed by the inverse DFT. Finally, a window \(\red{w}\) (e.g., Hann) is constructed for length \(K\) and multiplied by \(\blue{h}\) to produce the windowed filter:

\[\purple{h_w[k]} = \red{w[k]} \cdot \blue{h[k]}.\]

This process is visually demonstrated by Fig. 10.6.

Illustration of windowing applied to an ideal low-pass filter

Fig. 10.6 Left: the impulse response of an ideal low-pass filter is multiplied by a window function to produce the windowed low-pass filter (right).#

The scipy package helpfully implements all of this (and more) for us in one place: scipy.signal.firwin (finite impulse response + window). For example, we could construct a Hann-windowed low-pass filter at \(f_c=500\) by the following code:

f_cutoff = 500

# Two cycles of the cutoff frequency (in samples)
order = 2 * fs // f_cutoff

# Build the filter
hw = scipy.signal.firwin(order, f_cutoff, window='hann', fs=fs)

# Apply the filter to a signal
y = scipy.signal.convolve(x, hw)

The examples below demonstrate this process for Hann-windowed filters with \(p=1,2,\) and \(4\) cycles of the cutoff frequency, and the filters are visualized in Fig. 10.7.

# Make the filter long enough to catch two cycles of our cutoff frequency
order1 = 2 * fs // f_cutoff
hw1 = scipy.signal.firwin(order1, f_cutoff, window='hann', fs=fs)
y_hw1 = scipy.signal.convolve(x, hw1, mode='same')

# Or four cycles
order2 = 4 * fs // f_cutoff
hw2 = scipy.signal.firwin(order2, f_cutoff, window='hann', fs=fs)
y_hw2 = scipy.signal.convolve(x, hw2, mode='same')

# Or 8 cycles
order3 = 8 * fs // f_cutoff
hw3 = scipy.signal.firwin(order3, f_cutoff, window='hann', fs=fs)
y_hw3 = scipy.signal.convolve(x, hw3, mode='same')

# Or 16 cycles
order4 = 16 * fs // f_cutoff
hw4 = scipy.signal.firwin(order4, f_cutoff, window='hann', fs=fs)
y_hw4 = scipy.signal.convolve(x, hw4, mode='same')

display(f'Hann-windowed low-pass filter, order={order1}')
display(Audio(data=y_hw1, rate=fs))
display(f'Hann-windowed low-pass filter, order={order2}')
display(Audio(data=y_hw2, rate=fs))
display(f'Hann-windowed low-pass filter, order={order3}')
display(Audio(data=y_hw3, rate=fs))
display(f'Hann-windowed low-pass filter, order={order4}')
display(Audio(data=y_hw4, rate=fs))
'Hann-windowed low-pass filter, order=88'
'Hann-windowed low-pass filter, order=176'
'Hann-windowed low-pass filter, order=352'
'Hann-windowed low-pass filter, order=705'
Frequency response for Hann-windowed filters of different lengths

Fig. 10.7 Left: Hann-windowed low-pass filters for \(p=2,4,8,16\) cycles of the cutoff frequency \(\red{f_c=500}\) (with \(f_s=44100\)). Middle: the frequency domain representation of the windowed filters (decibel-scaled). Right: a zoomed-in view of the filter DFTs, covering the frequency range \([0, 1000]\) Hz. Note that as the filter length increases, so does the slope of the filter for frequencies above \(\red{f_c}\), as well as the number of DFT coefficients.#

From Fig. 10.7, we can see that taking longer filters (higher order) results in a better approximation to the ideal LPF, and the output of the filters in each case does not have the ringing artifacts of the ideal LPF.

When the cutoff frequency \(f_c\) is high, the window method can work well. However, when the cutoff frequency is low (like in this example) this method requires extremely long filters to achieve narrow transition bands.

10.3.3. The Parks-McClellan method#

The window method is not the only way to create a filter. Probably the next most commonly used method is due to Parks and McClellan [PM72], and it attempts to minimize ripple in both the pass and stop bands. The algorithm is known alternately as Parks-McClellan or Remez, the latter after Evgeny Remez, who developed the underlying method to solve the optimization problem several decades earlier [Rem34].

Unlike the window method, the Parks-McClellan method requires the user to explicitly describe the transition band, and the desired gain in each band (pass and stop). In Python, this can be done as follows:

f_cutoff = 500

# One cycle of the cutoff frequency
order = fs // f_cutoff

# Build the filter using the Remez method
# We'll use the following bands:
#    Pass band: [0, 0.75 * f_cutoff]  (begin to transition at 75% of our cutoff)
#          gain: 1
#    Stop band: [f_cutoff, fs/2]  (attenuate everything above the cutoff)
#          gain: 0

hpm = scipy.signal.remez(order, 
                             [0, 0.75 * f_cutoff, f_cutoff, fs/2],  # Our frequency bands
                             [1, 0],  # Our target gain for each band
                             fs=fs)

# Apply the filter to a signal
y = scipy.signal.convolve(x, hpm)

This example starts the transition band before \(f_c\), allowing a flat frequency response in the stop band as demonstrated below in Fig. 10.8. The following example outputs demonstrate this filter design applied to our example at multiple orders. Just as with the window method, higher orders are more precise, but less computationally efficient.

# Make the filter long enough to catch two cycles of our cutoff frequency
order1 = 2 * fs // f_cutoff
hpm1 = scipy.signal.remez(order1, 
                             [0, 0.75 * f_cutoff, 1.0 * f_cutoff, fs/2],
                             [1., 0.0],
                             fs=fs)

y_hpm1 = scipy.signal.convolve(x, hpm1, mode='same')

# Or four cycles
order2 = 4 * fs // f_cutoff
hpm2 = scipy.signal.remez(order2,
                             [0, 0.75 * f_cutoff, 1.0 * f_cutoff, fs/2],
                             [1., 0.0],
                             fs=fs)

y_hpm2 = scipy.signal.convolve(x, hpm2, mode='same')


# Or 8 cycles
order3 = 8 * fs // f_cutoff
hpm3 = scipy.signal.remez(order3,
                             [0, 0.75 * f_cutoff, 1.0 * f_cutoff, fs/2],
                             [1., 0.0],
                             fs=fs)

y_hpm3 = scipy.signal.convolve(x, hpm3, mode='same')


# Or 16 cycles
order4 = 16 * fs // f_cutoff
hpm4 = scipy.signal.remez(order4,
                             [0, 0.75 * f_cutoff, 1.0 * f_cutoff, fs/2],
                             [1., 0.0],
                             fs=fs)

y_hpm4 = scipy.signal.convolve(x, hpm4, mode='same')


# Display the results
display(f'Parks-McClellan filter, order={order1}')
display(Audio(data=y_hpm1, rate=fs))
display(f'Parks-McClellan filter, order={order2}')
display(Audio(data=y_hpm2, rate=fs))
display(f'Parks-McClellan filter, order={order3}')
display(Audio(data=y_hpm3, rate=fs))
display(f'Parks-McClellan filter, order={order4}')
display(Audio(data=y_hpm4, rate=fs))
'Parks-McClellan filter, order=88'
'Parks-McClellan filter, order=176'
'Parks-McClellan filter, order=352'
'Parks-McClellan filter, order=705'
frequency response plots for Parks-McClellan filters of different lengths

Fig. 10.8 Parks-McClellan filters with transition band \([3/4 \cdot f_c, f_c] = [375, 500]\) Hz (shaded regions) at multiple orders (\(p=2,4,8,16\) cycles of \(f_c\)). As in Fig. 10.7, increasing the order improves the attenuation of the filter.#

Comparing the frequency responses of window-method filters Fig. 10.7 to those of the Parks-McClellan filters Fig. 10.8, we can see that the windowed filters have gentler slope, and the Parks-McClellan filters have a steeper transition with a flat response in the stop-band. Both are effective at suppressing high-frequency content, though neither method will completely remove energy at frequencies above \(f_c\). That’s okay though — usually all we care about when filtering is reducing the stop-band energy to the point where it is not perceptible.