Skip to content

Commit 347d047

Browse files
Merge branch 'master' into docs-prompt-docstring
2 parents a3382fe + 1813f7d commit 347d047

13 files changed

Lines changed: 1130 additions & 92 deletions

audio_filters/README.md

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,58 @@
1-
# Audio Filter
1+
# Audio Filters
22

3-
Audio filters work on the frequency of an audio signal to attenuate unwanted frequency and amplify wanted ones.
4-
They are used within anything related to sound, whether it is radio communication or a hi-fi system.
3+
Audio filters work on the frequency of an audio signal to attenuate unwanted
4+
frequencies and amplify wanted ones. They are used within anything related to
5+
sound, whether it is radio communication or a hi-fi system. If you have ever
6+
turned up the bass or cut the treble on a stereo, tuned a radio to a station, or
7+
removed the background hum from a recording, you have used an audio filter.
8+
9+
Curious to learn more? These are great starting points:
510

611
* <https://www.masteringbox.com/filter-types/>
712
* <http://ethanwiner.com/filters.html>
813
* <https://en.wikipedia.org/wiki/Audio_filter>
914
* <https://en.wikipedia.org/wiki/Electronic_filter>
15+
* <https://webaudio.github.io/Audio-EQ-Cookbook/audio-eq-cookbook.html>
16+
17+
## What's in this directory
18+
19+
| File | Description |
20+
| ---- | ----------- |
21+
| [`iir_filter.py`](iir_filter.py) | A generic N-order [Infinite Impulse Response (IIR)](https://en.wikipedia.org/wiki/Infinite_impulse_response) filter. This is the engine every filter below runs on: give it a set of coefficients and it processes a stream of samples one at a time. |
22+
| [`butterworth_filter.py`](butterworth_filter.py) | A collection of second-order [Butterworth](https://en.wikipedia.org/wiki/Butterworth_filter) / biquad filter designs from the RBJ Audio EQ Cookbook. Each function returns a ready-to-use `IIRFilter`. |
23+
| [`equal_loudness_filter.py`](equal_loudness_filter.py) | An [equal-loudness](https://en.wikipedia.org/wiki/Equal-loudness_contour) filter that compensates for the human ear's non-linear response to sound by cascading a Yule-Walker filter and a Butterworth high-pass filter. Includes a dependency-free `yulewalk` implementation. |
24+
| [`show_response.py`](show_response.py) | Helpers to plot the [magnitude and phase response](https://en.wikipedia.org/wiki/Frequency_response) of any filter so you can *see* what it does. |
25+
| [`loudness_curve.json`](loudness_curve.json) | The Robinson-Dadson equal-loudness contour data used by the equal-loudness filter. |
26+
27+
## Filter designs in `butterworth_filter.py`
28+
29+
| Function | Effect |
30+
| -------- | ------ |
31+
| `make_lowpass` | Passes frequencies below the cutoff, attenuates those above it. |
32+
| `make_highpass` | Passes frequencies above the cutoff, attenuates those below it. |
33+
| `make_bandpass` | Passes a band of frequencies around the center (constant skirt gain). |
34+
| `make_bandpass_peak` | Passes a band of frequencies around the center (constant 0 dB peak gain). |
35+
| `make_notch` | Rejects a narrow band around the center — great for removing mains hum. |
36+
| `make_allpass` | Passes all frequencies but changes their phase relationship. |
37+
| `make_peak` | Boosts or cuts a band around the center by a given gain (parametric EQ). |
38+
| `make_lowshelf` | Boosts or cuts everything below the cutoff. |
39+
| `make_highshelf` | Boosts or cuts everything above the cutoff. |
40+
41+
## Try it out
42+
43+
```python
44+
from audio_filters.butterworth_filter import make_lowpass
45+
from audio_filters.show_response import show_frequency_response
46+
47+
# A 5 kHz low-pass filter for CD-quality audio (44.1 kHz sample rate)
48+
filt = make_lowpass(5000, 44100)
49+
50+
# Process samples one at a time...
51+
filtered = [filt.process(sample) for sample in my_audio_samples]
52+
53+
# ...or visualise what the filter does to the spectrum:
54+
show_frequency_response(make_lowpass(5000, 44100), 44100)
55+
```
56+
57+
Every module has runnable doctests — read them for concrete, copy-pasteable
58+
examples of each filter in action.

audio_filters/butterworth_filter.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,16 @@
77
88
Code based on https://webaudio.github.io/Audio-EQ-Cookbook/audio-eq-cookbook.html
99
Alternatively you can use scipy.signal.butter, which should yield the same results.
10+
11+
https://en.wikipedia.org/wiki/Butterworth_filter
12+
13+
Notation used throughout this module (from the RBJ Audio EQ Cookbook):
14+
w0 -- normalised angular frequency, ``2 * pi * frequency / samplerate``
15+
alpha -- bandwidth parameter, ``sin(w0) / (2 * q_factor)``
16+
b0..b2 -- feed-forward (numerator) coefficients of the biquad
17+
a0..a2 -- feed-back (denominator) coefficients of the biquad
18+
The a/b coefficient names match ``IIRFilter.set_coefficients`` and the standard
19+
biquad transfer function, so they are kept consistent across every filter here.
1020
"""
1121

1222

@@ -232,3 +242,81 @@ def make_highshelf(
232242
filt = IIRFilter(2)
233243
filt.set_coefficients([a0, a1, a2], [b0, b1, b2])
234244
return filt
245+
246+
247+
def make_notch(
248+
frequency: int,
249+
samplerate: int,
250+
q_factor: float = 1 / sqrt(2),
251+
) -> IIRFilter:
252+
"""
253+
Creates a notch (band-reject) filter that strongly attenuates a narrow band
254+
of frequencies around ``frequency`` while leaving the rest of the spectrum
255+
unchanged. It is the complement of the band-pass filter and is commonly used
256+
to remove a single tone such as 50/60 Hz mains hum.
257+
258+
https://en.wikipedia.org/wiki/Band-stop_filter
259+
260+
>>> filter = make_notch(1000, 48000)
261+
>>> filter.a_coeffs + filter.b_coeffs # doctest: +NORMALIZE_WHITESPACE
262+
[1.0922959556412573, -1.9828897227476208, 0.9077040443587427, 1.0,
263+
-1.9828897227476208, 1.0]
264+
"""
265+
w0 = tau * frequency / samplerate # centre frequency, in radians/sample
266+
_sin = sin(w0)
267+
_cos = cos(w0)
268+
alpha = _sin / (2 * q_factor) # controls how narrow the rejected band is
269+
270+
# Feed-forward: a pair of zeros placed exactly on the notch frequency, so
271+
# that frequency is fully cancelled while the rest of the spectrum passes.
272+
b0 = 1.0
273+
b1 = -2 * _cos
274+
b2 = 1.0
275+
276+
# Feed-back: matching poles just inside the unit circle keep the notch
277+
# narrow and the surrounding gain flat.
278+
a0 = 1 + alpha
279+
a1 = -2 * _cos
280+
a2 = 1 - alpha
281+
282+
filt = IIRFilter(2)
283+
filt.set_coefficients([a0, a1, a2], [b0, b1, b2])
284+
return filt
285+
286+
287+
def make_bandpass_peak(
288+
frequency: int,
289+
samplerate: int,
290+
q_factor: float = 1 / sqrt(2),
291+
) -> IIRFilter:
292+
"""
293+
Creates a band-pass filter with constant 0 dB peak gain.
294+
295+
Unlike :func:`make_bandpass`, whose skirt (edge) gain is held constant so the
296+
peak gain grows with ``q_factor``, this variant normalises the response so
297+
the peak always reaches 0 dB regardless of the chosen ``q_factor``. Both
298+
forms come from the RBJ Audio EQ Cookbook.
299+
300+
https://en.wikipedia.org/wiki/Band-pass_filter
301+
302+
>>> filter = make_bandpass_peak(1000, 48000)
303+
>>> filter.a_coeffs + filter.b_coeffs # doctest: +NORMALIZE_WHITESPACE
304+
[1.0922959556412573, -1.9828897227476208, 0.9077040443587427,
305+
0.09229595564125725, 0, -0.09229595564125725]
306+
"""
307+
w0 = tau * frequency / samplerate
308+
_sin = sin(w0)
309+
_cos = cos(w0)
310+
alpha = _sin / (2 * q_factor)
311+
312+
b0 = alpha
313+
b1 = 0
314+
b2 = -alpha
315+
316+
a0 = 1 + alpha
317+
a1 = -2 * _cos
318+
a2 = 1 - alpha
319+
320+
filt = IIRFilter(2)
321+
filt.set_coefficients([a0, a1, a2], [b0, b1, b2])
322+
return filt
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
from __future__ import annotations
2+
3+
from json import loads
4+
from pathlib import Path
5+
6+
import numpy as np
7+
from scipy.linalg import toeplitz
8+
from scipy.signal import lfilter, unit_impulse
9+
10+
from audio_filters.butterworth_filter import make_highpass
11+
from audio_filters.iir_filter import IIRFilter
12+
13+
data = loads((Path(__file__).resolve().parent / "loudness_curve.json").read_text())
14+
15+
16+
def _polystab(poly: np.ndarray) -> np.ndarray:
17+
"""
18+
Stabilize a polynomial by reflecting any roots that lie outside the unit
19+
circle back inside it. This keeps the resulting IIR filter stable without
20+
changing its magnitude response.
21+
22+
https://en.wikipedia.org/wiki/Minimum_phase
23+
24+
>>> np.round(_polystab(np.array([1.0, 2.0, 1.0])), 6)
25+
array([1., 2., 1.])
26+
>>> np.round(_polystab(np.array([1.0, 2.0, 1.01])), 6)
27+
array([1. , 1.980198, 0.990099])
28+
"""
29+
if poly.size <= 1:
30+
return poly
31+
roots = np.roots(poly)
32+
nonzero = np.where(roots != 0)[0]
33+
outside = 0.5 * (np.sign(np.abs(roots[nonzero]) - 1) + 1)
34+
roots[nonzero] = (1 - outside) * roots[nonzero] + outside / np.conj(roots[nonzero])
35+
stabilized = np.poly(roots)
36+
if not np.imag(poly).any():
37+
stabilized = np.real(stabilized)
38+
return stabilized
39+
40+
41+
def _numerator(
42+
impulse_response: np.ndarray, denominator: np.ndarray, numerator_order: int
43+
) -> np.ndarray:
44+
"""
45+
Least-squares estimate of the numerator polynomial of a transfer function
46+
given its impulse response and (already known) denominator polynomial.
47+
48+
>>> num = _numerator(np.array([1.0, 0.0, 0.0]), np.array([1.0, 0.0, 0.0]), 1)
49+
>>> np.round(num, 6)
50+
array([1., 0.])
51+
"""
52+
length = impulse_response.size
53+
impulse = lfilter([1.0], denominator.ravel(), unit_impulse(length))
54+
toep = toeplitz(impulse, unit_impulse(numerator_order + 1))
55+
return np.linalg.lstsq(toep.conj(), impulse_response.ravel().conj(), rcond=None)[
56+
0
57+
].conj()
58+
59+
60+
def yulewalk(
61+
order: int, frequencies: np.ndarray, magnitudes: np.ndarray, npt: int = 512
62+
) -> tuple[np.ndarray, np.ndarray]:
63+
"""
64+
Design a recursive (IIR) digital filter that approximates an arbitrary
65+
frequency response using the modified Yule-Walker method. This is a
66+
dependency-free re-implementation of MATLAB/Octave's ``yulewalk`` so that
67+
the equal-loudness filter below no longer relies on a third-party package.
68+
69+
https://en.wikipedia.org/wiki/Autoregressive_model#Yule%E2%80%93Walker_equations
70+
71+
:param order: order of the filter to design
72+
:param frequencies: sample points on ``[0, 1]`` where 1 is the Nyquist
73+
frequency, in increasing order and starting at 0
74+
:param magnitudes: desired (linear) magnitude at each point in ``frequencies``
75+
:param npt: number of points used to estimate the frequency response
76+
:return: ``(a_coeffs, b_coeffs)``, the denominator and numerator polynomials
77+
78+
>>> a, b = yulewalk(4, np.array([0.0, 0.5, 1.0]), np.array([1.0, 0.5, 0.0]))
79+
>>> len(a), len(b)
80+
(5, 5)
81+
>>> bool(np.all(np.abs(np.roots(a)) < 1)) # the designed filter is stable
82+
True
83+
84+
Mismatched inputs and non-increasing frequencies are rejected:
85+
86+
>>> yulewalk(4, np.array([0.0, 1.0]), np.array([1.0]))
87+
Traceback (most recent call last):
88+
...
89+
ValueError: frequencies and magnitudes must have the same length
90+
>>> yulewalk(4, np.array([0.0, 1.0, 0.5]), np.array([1.0, 0.5, 0.0]))
91+
Traceback (most recent call last):
92+
...
93+
ValueError: frequencies must be in increasing order
94+
"""
95+
frequencies = np.asarray(frequencies, dtype=float).ravel()
96+
magnitudes = np.asarray(magnitudes, dtype=float).ravel()
97+
if frequencies.size != magnitudes.size:
98+
msg = "frequencies and magnitudes must have the same length"
99+
raise ValueError(msg)
100+
if np.any(np.diff(frequencies) < 0):
101+
msg = "frequencies must be in increasing order"
102+
raise ValueError(msg)
103+
104+
npt = npt + 1
105+
# Linearly interpolate the target response onto a dense grid, then mirror it
106+
# to build the full (symmetric) magnitude spectrum.
107+
response = np.interp(np.linspace(0, 1, npt), frequencies, magnitudes)
108+
response = np.concatenate([response, response[-2:0:-1]])
109+
110+
total = response.size
111+
half = (total + 1) // 2
112+
window_len = 4 * order
113+
index = np.arange(window_len)
114+
115+
# Autocorrelation from the power spectrum, tapered with a Hamming window.
116+
correlation = np.real(np.fft.ifft(response * response))
117+
correlation = correlation[:window_len] * (
118+
0.54 + 0.46 * np.cos(np.pi * index / (window_len - 1))
119+
)
120+
cepstral_window = np.concatenate([[0.5], np.ones(half - 1), np.zeros(total - half)])
121+
122+
# Solve the Yule-Walker normal equations for the denominator coefficients.
123+
rmat = toeplitz(correlation[order : window_len - 1], correlation[order:0:-1])
124+
rhs = -correlation[order + 1 : window_len]
125+
denominator = np.concatenate([[1.0], np.linalg.lstsq(rmat, rhs, rcond=None)[0]])
126+
denominator = _polystab(denominator)
127+
128+
half_correlation = correlation.copy()
129+
half_correlation[0] = correlation[0] / 2
130+
numerator = _numerator(half_correlation, denominator, order)
131+
132+
padded_num = np.zeros(total)
133+
padded_num[: numerator.size] = numerator
134+
padded_den = np.zeros(total)
135+
padded_den[: denominator.size] = denominator
136+
137+
spectrum = 2 * np.real(np.fft.fft(padded_num) / np.fft.fft(padded_den))
138+
complex_log = np.log(np.abs(spectrum)) + 1j * np.angle(spectrum)
139+
cepstrum = np.fft.ifft(
140+
np.exp(np.fft.fft(cepstral_window * np.fft.ifft(complex_log)))
141+
)
142+
numerator = np.real(_numerator(cepstrum[:window_len], denominator, order))
143+
return denominator, numerator
144+
145+
146+
class EqualLoudnessFilter:
147+
r"""
148+
An equal-loudness filter which compensates for the human ear's non-linear
149+
response to sound. This filter corrects this by cascading a Yule-Walker
150+
filter and a Butterworth filter.
151+
152+
Designed for use with samplerate of 44.1kHz and above. If you're using a
153+
lower samplerate, use with caution.
154+
155+
Code based on the matlab implementation at https://bit.ly/3eqh2HU
156+
(url shortened for ruff)
157+
158+
Target curve: https://i.imgur.com/3g2VfaM.png
159+
Yulewalk response: https://i.imgur.com/J9LnJ4C.png
160+
Butterworth and overall response: https://i.imgur.com/3g2VfaM.png
161+
162+
Images and original matlab implementation by David Robinson, 2001
163+
164+
https://en.wikipedia.org/wiki/Equal-loudness_contour
165+
166+
>>> filt = EqualLoudnessFilter()
167+
>>> isinstance(filt.yulewalk_filter, IIRFilter)
168+
True
169+
"""
170+
171+
def __init__(self, samplerate: int = 44100) -> None:
172+
self.yulewalk_filter = IIRFilter(10)
173+
self.butterworth_filter = make_highpass(150, samplerate)
174+
175+
# pad the data to nyquist
176+
curve_freqs = np.array(data["frequencies"] + [max(20000.0, samplerate / 2)])
177+
curve_gains = np.array(data["gains"] + [140])
178+
179+
# Convert to angular frequency
180+
freqs_normalized = curve_freqs / samplerate * 2
181+
# Invert the curve and normalize to 0dB
182+
gains_normalized = np.power(10, (np.min(curve_gains) - curve_gains) / 20)
183+
184+
# Compute the coefficients using a least-squares fit to the curve with
185+
# the built-in ``yulewalk`` implementation above (no third-party deps).
186+
ya, yb = yulewalk(10, freqs_normalized, gains_normalized)
187+
self.yulewalk_filter.set_coefficients(ya.tolist(), yb.tolist())
188+
189+
def process(self, sample: float) -> float:
190+
"""
191+
Process a single sample through both filters
192+
193+
>>> filt = EqualLoudnessFilter()
194+
>>> filt.process(0.0)
195+
0.0
196+
"""
197+
tmp = self.yulewalk_filter.process(sample)
198+
return self.butterworth_filter.process(tmp)

0 commit comments

Comments
 (0)