This article introduces an adaptation of the Chebyshev filter for musical applications. The approach focuses on defining a user-friendly resonance parameter, normalizing both the cutoff frequency and output gain, and applying filter transformations to conform to standard filter types: low-pass, high-pass, band-pass, and notch.
Chebyshev Polynomials
To design the Chebyshev filter, we use the Chebyshev polynomial \(T_n\) of order \(n\). Various definitions for \(T_n\) can be found here. For efficient implementations, one might opt for a recursive approach or precompute the polynomials.
For now, we concentrate on the essential requirements, using the following definition for \(T_n\) and its inverse \(T_n^{-1}\):
This equation ensures that the peak magnitude remains consistent regardless of the filter order and matches that of a standard second-order filter. Therefore, for a second-order Chebyshev filter, the Q control will behave the same as in a typical second-order filter.
Q =6# Quality factor# Calculate epsilon based on the Q factoreps = (2* Q**2-1) / (4* Q**2-1)**0.5# Loop through specified filter ordersfor n in [8, 2]:
# Calculate and plot the gain g g = np.sqrt((1+ eps**2) / (1+ (eps * np.vectorize(T)(n, w))**2))
plt.plot(np.log2(w), 20* np.log10(g), label="\\(n="+ str(n) +"\\)")
plt.title("Q-Factor Definition (Q="+ str(Q) +")")
plt.xlabel('Frequency (octaves)')
plt.ylabel('Magnitude (dB)')
plt.ylim(-30, 30)
# Add the Q factor in dB to the y-axis ticksQ_dB =20* np.log10(Q) # Calculate the Q factor in dBcurrent_ticks = plt.yticks()[0]
new_ticks = np.append(current_ticks, Q_dB)
plt.yticks(new_ticks, labels=[f"{int(tick)}"if tick != Q_dB elser"\\(20\log_{10}(Q)\\)"for tick in new_ticks])
plt.legend()
plt.show()
Python - Console Output
Normalization of the Cutoff Frequency
Several strategies can be employed to normalize the cutoff frequency of the filter.
The first and most intuitive approach is to normalize the frequency so that the asymptotic gain as \(\omega \to +\infty\) matches that of a Butterworth filter. This ensures that the modulation of the Q-factor only alters the shape of the magnitude around \(\omega_c.\) For our filter, this normalization involve taking as its cut frequency:
However, in practice, this normalization can result in significant jumps in the frequency response when modulating the Q factor.
A second approach normalizes the frequency so that the filter’s gain at (\omega = 1) matches that of a second-order filter. Since the Q factor corresponds to the linear gain at (\omega = 1), it is sensible to maintain this property as a constraint. For this reason, and because this method does not produce the significant jumps seen with the first method, we choose it for normalization. The cutoff frequency is defined as:
Finally, another closely related approach normalizes the frequency so that the most resonant section of the filter has a cutoff frequency of 1. This method simplifies the calculations (hint: (\sinh/\cosh = \tanh)) and is given by the following relation, where (\tilde{p}_k) denotes the poles of the Chebyshev filter, which we will define later:
$$
\omega_{\text{norm3}} = |\tilde{p}_{0}|
$$
Calculation of Poles and Zeros and Normalization of the Cutoff Frequency
The poles and zeros of a Chebyshev filter are given by the following equations^1:
import numpy as np
import matplotlib.pyplot as plt
w = np.logspace(-5, 5, 50000, base=2) # Generate 10 octaves (~20Hz to 20kHz)n =8# Filter order# Set up the figure for polesplt.figure(figsize=(8, 8))
plt.subplot(111, projection='polar')
# Loop over various Q factorsfor Q in [0.5**0.5, 0.71, 0.8, 2, 4]:
# Calculate the poles for a standard Chebyshev filter eps = (2* Q**2-1) / (4* Q**2-1)**0.5# Compute epsilon to keep the right dB peak m = np.arange(0, n //2) # Half the number of poles theta = np.pi /2* (2* m +1) / n # Calculate angles for poles ar = np.arcsinh(1/ eps) / n # Compute inverse hyperbolic sine p =-np.sinh(ar) * np.sin(theta) +1j * np.cosh(ar) * np.cos(theta) # Calculate pole locations# Normalize the poles based on the chosen method w_norm = T_inv(n, 1/ (2* Q**2-1)) # Normalize to match Butterworth filter gain at w = 1 p /= w_norm # Apply normalization p = np.append(p, np.conj(p)) # Include complex conjugate poles# Plot pole locations in the s-plane plt.figure(1)
title ='Q = '+ str(Q if Q !=0.5**0.5else"\\(0.5^{0.5}\\)")
plt.plot(np.angle(p), np.abs(p), 'x', markersize=10, label=title)
# Plot corresponding magnitude response s =1j * w
H = np.ones_like(s)
for p_ in p:
H *=1/ (1- s / p_)
plt.figure(2)
plt.plot(np.log2(w), 20* np.log10(np.abs(H)), label=title)
# Finalize the pole location plotplt.figure(1)
plt.title("Poles Location on the s-plane")
plt.legend()
# Finalize the magnitude response plotplt.figure(2)
plt.title("Corresponding Magnitude Response")
plt.xlabel('Frequency (octaves)')
plt.ylabel('Magnitude (dB)')
plt.ylim(-30, 30)
plt.legend()
plt.show()
Python - Console Output
Python - Console Output
Calculation of Extrema
The frequencies of the local extrema are given by the expression:
$$w_{m} = \frac{1}{w_{2}} \cos\left(\frac{m\pi}{2n}\right), \quad m = \left[1, \ldots, n\right]$$
Python
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# Plot the overall magnitude response with a dashed lineplt.plot(np.log2(w), 20* np.log10(abs(H)), "--", alpha=0.2, label="Magnitude")
# Calculate the frequencies of local extremaw_extremums = np.cos(np.arange(1, n) * np.pi / (2* n)) / w_norm
# Plot the local extrema on the same graphs_extremums =1j * w_extremums
H_extremums = np.ones_like(s_extremums)
for p_ in p:
H_extremums *=1/ (1- s_extremums / p_)
plt.plot(np.log2(w_extremums), 20* np.log10(abs(H_extremums)), "+", markersize=10, c="C0", label="Local Extremums")
plt.title("Local Extrema")
plt.xlabel('Frequency (octaves)')
plt.ylabel('Magnitude (dB)')
plt.legend()
plt.ylim(-30, 30)
plt.show()
Python - Console Output
Factorization into Second-Order Sections
In digital implementations, complex poles require the use of second-order sections (SOS) to maintain real coefficients and enhance numerical stability. Each section corresponds to a pair of conjugate poles. The overall transfer function can be expressed as the product of \(n/2\) second-order sections:
Similarly to the bandpass version, the fourth-order denominator can also be factored into two second-order sections using a root solver described here.
Currently, the Chebyshev filter does not support a \(Q\) value less than or equal to \(\frac{\sqrt{2}}{2}\). To enhance our musical Chebyshev filter’s flexibility, we aim to enable underdamping, aligning its behavior with our other filters.
Strategy
Our proposed strategy is to utilize the Butterworth resonant filter formula for \(Q \leq \frac{\sqrt{2}}{2}\), and the existing Chebyshev filter formula for \(Q > \frac{\sqrt{2}}{2}\). As \(Q\) approaches \(\frac{\sqrt{2}}{2}\), the Chebyshev filter behaves increasingly like a Butterworth filter. This approach ensures a smooth transition between the two filter types. Notably, we retain the property that for second-order filters, our musical Chebyshev acts as a simple second-order filter with an appropriate definition of \(Q\).
Smoothing the Transition
Although the transition between the Chebyshev and Butterworth resonant filters is continuous, a noticeable jump can occur in practice at higher orders. Thus, implementing a smooth morphing between the two filter types is advantageous.
To achieve this, we decompose the \(Q\) factor into two parameters: \(Q_{\text{res}}\) and \(Q_{\text{damp}}\), ensuring their application compensates for each other by maintaining a consistent gain at the cutoff frequency. This constraint leads to the relationship:
$$
Q = Q_{\text{res}} \cdot Q_{\text{damp}}
$$
Here, \(Q_{\text{res}}\) is the \(Q\) factor used for evaluating the Chebyshev filter, and we want \(Q_{\text{res}} > \frac{\sqrt{2}}{2}\). Conversely, \(Q_{\text{damp}}\) represents the additional resonance applied to the most resonant section, similar to the approach used for Butterworth resonant filters, described here. We desire \(Q_{\text{damp}} < \frac{\sqrt{2}}{2}\).
The following functions \(f_1\) and \(f_2\) are suitable candidates for this transition, selected for their mathematical simplicity and symmetric behavior in log-log scaling, as the end user will control the \(Q\) factor through a logarithmic mapping. The empirical value of \(k\) defines the range of \(Q\) values over which the transition occurs; we set it to 8 for efficient computational performance:
The code below summarizes the calculation of the second-order sections (SOS) for the musical Chebyshev prototype, as well as the computation of its magnitude without directly using its transfer function. It includes the underdamped capability we just explained, with the smooth transition to the butterworth resonant filter that we studied here.
import numpy as np
import matplotlib.pyplot as plt
# Filter parametersn =8w = np.logspace(-5, 5, 50000, base=2) # Frequency range: 10 octaves (20 Hz to 20 kHz)s =1j * w
k =8# Exponent for smoothing# Loop through varying Q valuesfor j, Q in enumerate(np.exp(np.linspace(np.log(0.5**0.5/40), np.log(0.5**0.5*40), 10))):
# Split Q into resonant (Q_res) and damping (Q_damp) parts Q_res = (Q**k + (0.5**0.5)**k)**(1/k)
Q_damp = Q / Q_res
# Calculate the first half of the poles for the Chebyshev filter eps = (2* Q_res**2-1) / (4* Q_res**2-1)**0.5 m = np.arange(0, n //2)
theta = np.pi /2* (2* m +1) / n
ar = np.arcsinh(1/ eps) / n
p =-np.sinh(ar) * np.sin(theta) +1j * np.cosh(ar) * np.cos(theta)
# Normalize poles to match Butterworth filter gain at w = 1 w_norm = T_inv(n, 1/ (2* Q_res**2-1))
p /= w_norm
# Compute SOS response and plot its magnitude H = np.ones_like(s)
a0 = np.ones(len(p))
a1 =-p - np.conj(p)
a2 = p * np.conj(p)
a1[0] /= Q_damp # Adjust first coefficient for dampingfor i in range(len(p)):
H *=1/ (a0[i] * s**2/ a2[i] + a1[i] * s / a2[i] +1)
plt.plot(np.log2(w), 20* np.log10(abs(H)), label=f"\\(Q={round(Q, 2)}\\)", c=f"C{j}")
# # Compute magnitude response using Chebyshev magnitude formula# g2 = (1 + eps**2) / (1 + (eps**2 * T(n, w * w_norm)**2))# wPeak = np.sqrt(np.real(p[0])**2 + np.imag(p[0])**2)# QPeak = wPeak / (-2 * np.real(p[0]))# w2 = w * w / (wPeak**2)# g2 *= 1 + (1 / (QPeak**2) - 2) * w2 + w2**2# g2 /= 1 + (1 / (Q_damp**2 * QPeak**2) - 2) * w2 + w2**2# plt.plot(np.log2(w), 20*np.log10(g2**0.5), c="C" + str(j)) # Chebyshev computed via magnitude formula# # Plot the Butterworth resonant response# p_butter = np.sin(theta) + 1j * np.cos(theta)# H_butter = np.ones_like(s)# a0_butter = np.ones(len(p_butter))# a1_butter = -p_butter - np.conj(p_butter)# a2_butter = p_butter * np.conj(p_butter)# a1_butter[0] /= np.sqrt(2) * Q # Adjust first coefficient for Butterworth# for i in range(len(p_butter)):# H_butter *= 1 / (a0_butter[i] * s**2 / a2_butter[i] + a1_butter[i] * s / a2_butter[i] + 1)# plt.plot(np.log2(w), 20*np.log10(abs(H)), c="C" + str(j)) # Butterworth computed via sos# Plot settingsplt.ylim(-30, 30)
plt.title("Adding Underdamping Capability to Chebyshev Filter")
plt.xlabel('Frequency (octaves)')
plt.ylabel('Magnitude (dB)')
plt.legend()
plt.show()
Python - Console Output
Conclusion
The exploration and implementation of the Musical Chebyshev filter have highlighted its remarkable versatility in sound design, particularly in creating distinctive phaser-style effects. Integrated into UVI Shade as a Multi Resonant filter, it excels in delivering sounds, especially when modulation is applied.
While the filter’s inherent ringing can be pronounced (sometimes overly sharp for certain musical applications), there is an exciting opportunity to develop a new parameter that could mitigate this effect.
Overall, the well-studied Chebyshev filter stands out as a powerful tool for sound designers!