Equal Loudness Filter
A
from __future__ import annotations
from json import loads
from pathlib import Path
import numpy as np
from scipy.linalg import toeplitz
from scipy.signal import lfilter, unit_impulse
from audio_filters.butterworth_filter import make_highpass
from audio_filters.iir_filter import IIRFilter
data = loads((Path(__file__).resolve().parent / "loudness_curve.json").read_text())
def _polystab(poly: np.ndarray) -> np.ndarray:
"""
Stabilize a polynomial by reflecting any roots that lie outside the unit
circle back inside it. This keeps the resulting IIR filter stable without
changing its magnitude response.
https://en.wikipedia.org/wiki/Minimum_phase
>>> np.round(_polystab(np.array([1.0, 2.0, 1.0])), 6)
array([1., 2., 1.])
>>> np.round(_polystab(np.array([1.0, 2.0, 1.01])), 6)
array([1. , 1.980198, 0.990099])
"""
if poly.size <= 1:
return poly
roots = np.roots(poly)
nonzero = np.where(roots != 0)[0]
outside = 0.5 * (np.sign(np.abs(roots[nonzero]) - 1) + 1)
roots[nonzero] = (1 - outside) * roots[nonzero] + outside / np.conj(roots[nonzero])
stabilized = np.poly(roots)
if not np.imag(poly).any():
stabilized = np.real(stabilized)
return stabilized
def _numerator(
impulse_response: np.ndarray, denominator: np.ndarray, numerator_order: int
) -> np.ndarray:
"""
Least-squares estimate of the numerator polynomial of a transfer function
given its impulse response and (already known) denominator polynomial.
>>> num = _numerator(np.array([1.0, 0.0, 0.0]), np.array([1.0, 0.0, 0.0]), 1)
>>> np.round(num, 6)
array([1., 0.])
"""
length = impulse_response.size
impulse = lfilter([1.0], denominator.ravel(), unit_impulse(length))
toep = toeplitz(impulse, unit_impulse(numerator_order + 1))
return np.linalg.lstsq(toep.conj(), impulse_response.ravel().conj(), rcond=None)[
0
].conj()
def yulewalk(
order: int, frequencies: np.ndarray, magnitudes: np.ndarray, npt: int = 512
) -> tuple[np.ndarray, np.ndarray]:
"""
Design a recursive (IIR) digital filter that approximates an arbitrary
frequency response using the modified Yule-Walker method. This is a
dependency-free re-implementation of MATLAB/Octave's ``yulewalk`` so that
the equal-loudness filter below no longer relies on a third-party package.
https://en.wikipedia.org/wiki/Autoregressive_model#Yule%E2%80%93Walker_equations
:param order: order of the filter to design
:param frequencies: sample points on ``[0, 1]`` where 1 is the Nyquist
frequency, in increasing order and starting at 0
:param magnitudes: desired (linear) magnitude at each point in ``frequencies``
:param npt: number of points used to estimate the frequency response
:return: ``(a_coeffs, b_coeffs)``, the denominator and numerator polynomials
>>> a, b = yulewalk(4, np.array([0.0, 0.5, 1.0]), np.array([1.0, 0.5, 0.0]))
>>> len(a), len(b)
(5, 5)
>>> bool(np.all(np.abs(np.roots(a)) < 1)) # the designed filter is stable
True
Mismatched inputs and non-increasing frequencies are rejected:
>>> yulewalk(4, np.array([0.0, 1.0]), np.array([1.0]))
Traceback (most recent call last):
...
ValueError: frequencies and magnitudes must have the same length
>>> yulewalk(4, np.array([0.0, 1.0, 0.5]), np.array([1.0, 0.5, 0.0]))
Traceback (most recent call last):
...
ValueError: frequencies must be in increasing order
"""
frequencies = np.asarray(frequencies, dtype=float).ravel()
magnitudes = np.asarray(magnitudes, dtype=float).ravel()
if frequencies.size != magnitudes.size:
msg = "frequencies and magnitudes must have the same length"
raise ValueError(msg)
if np.any(np.diff(frequencies) < 0):
msg = "frequencies must be in increasing order"
raise ValueError(msg)
npt = npt + 1
# Linearly interpolate the target response onto a dense grid, then mirror it
# to build the full (symmetric) magnitude spectrum.
response = np.interp(np.linspace(0, 1, npt), frequencies, magnitudes)
response = np.concatenate([response, response[-2:0:-1]])
total = response.size
half = (total + 1) // 2
window_len = 4 * order
index = np.arange(window_len)
# Autocorrelation from the power spectrum, tapered with a Hamming window.
correlation = np.real(np.fft.ifft(response * response))
correlation = correlation[:window_len] * (
0.54 + 0.46 * np.cos(np.pi * index / (window_len - 1))
)
cepstral_window = np.concatenate([[0.5], np.ones(half - 1), np.zeros(total - half)])
# Solve the Yule-Walker normal equations for the denominator coefficients.
rmat = toeplitz(correlation[order : window_len - 1], correlation[order:0:-1])
rhs = -correlation[order + 1 : window_len]
denominator = np.concatenate([[1.0], np.linalg.lstsq(rmat, rhs, rcond=None)[0]])
denominator = _polystab(denominator)
half_correlation = correlation.copy()
half_correlation[0] = correlation[0] / 2
numerator = _numerator(half_correlation, denominator, order)
padded_num = np.zeros(total)
padded_num[: numerator.size] = numerator
padded_den = np.zeros(total)
padded_den[: denominator.size] = denominator
spectrum = 2 * np.real(np.fft.fft(padded_num) / np.fft.fft(padded_den))
complex_log = np.log(np.abs(spectrum)) + 1j * np.angle(spectrum)
cepstrum = np.fft.ifft(
np.exp(np.fft.fft(cepstral_window * np.fft.ifft(complex_log)))
)
numerator = np.real(_numerator(cepstrum[:window_len], denominator, order))
return denominator, numerator
class EqualLoudnessFilter:
r"""
An equal-loudness filter which compensates for the human ear's non-linear
response to sound. This filter corrects this by cascading a Yule-Walker
filter and a Butterworth filter.
Designed for use with samplerate of 44.1kHz and above. If you're using a
lower samplerate, use with caution.
Code based on the matlab implementation at https://bit.ly/3eqh2HU
(url shortened for ruff)
Target curve: https://i.imgur.com/3g2VfaM.png
Yulewalk response: https://i.imgur.com/J9LnJ4C.png
Butterworth and overall response: https://i.imgur.com/3g2VfaM.png
Images and original matlab implementation by David Robinson, 2001
https://en.wikipedia.org/wiki/Equal-loudness_contour
>>> filt = EqualLoudnessFilter()
>>> isinstance(filt.yulewalk_filter, IIRFilter)
True
"""
def __init__(self, samplerate: int = 44100) -> None:
self.yulewalk_filter = IIRFilter(10)
self.butterworth_filter = make_highpass(150, samplerate)
# pad the data to nyquist
curve_freqs = np.array(data["frequencies"] + [max(20000.0, samplerate / 2)])
curve_gains = np.array(data["gains"] + [140])
# Convert to angular frequency
freqs_normalized = curve_freqs / samplerate * 2
# Invert the curve and normalize to 0dB
gains_normalized = np.power(10, (np.min(curve_gains) - curve_gains) / 20)
# Compute the coefficients using a least-squares fit to the curve with
# the built-in ``yulewalk`` implementation above (no third-party deps).
ya, yb = yulewalk(10, freqs_normalized, gains_normalized)
self.yulewalk_filter.set_coefficients(ya.tolist(), yb.tolist())
def process(self, sample: float) -> float:
"""
Process a single sample through both filters
>>> filt = EqualLoudnessFilter()
>>> filt.process(0.0)
0.0
"""
tmp = self.yulewalk_filter.process(sample)
return self.butterworth_filter.process(tmp)