Last active
June 1, 2016 16:23
-
-
Save notwa/3a5eb748f1a3949dfa91c1b8825d881c to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import numpy as np | |
import matplotlib.pyplot as plt | |
import scipy.signal as sig | |
# optionally, use an alternate style for plotting. | |
# this reconfigures colors, fonts, padding, etc. | |
# i personally prefer the look of ggplot, but the contrast is generally worse. | |
plt.style.use('ggplot') | |
# create log-spaced points from 20 to 20480 Hz. | |
# we'll compute our y values using these later. | |
# increasing precision will result in a smoother plot. | |
precision = 4096 | |
xs = np.arange(0, precision) / precision | |
xs = 20 * 1024**xs | |
# decide on a sample-rate and a center-frequency (the -3dB point). | |
fs = 44100 | |
fc = 1000 | |
def response_setup(ax, ymin=-24, ymax=24, major_ticks_every=6, minor_ticks_split=2): | |
"""configure axes for magnitude plotting within the range of human hearing.""" | |
ax.set_xlim(20, 20000) | |
ax.set_ylim(ymin, ymax) | |
ax.grid(True, 'both') # enable major and minor gridlines on both axes | |
ax.set_xlabel('frequency (Hz)') | |
ax.set_ylabel('magnitude (dB)') | |
# manually set y tick positions. | |
# this is usually better than relying on matplotlib to choose good values. | |
from matplotlib import ticker | |
ax.set_yticks(tuple(range(ymin, ymax + 1, major_ticks_every))) | |
minor_ticker = ticker.AutoMinorLocator(minor_ticks_split) | |
ax.yaxis.set_minor_locator(minor_ticker) | |
def modified_bilinear(b, a, w0): | |
""" | |
the modified bilinear transform defers specifying the center frequency | |
of an analog filter to the conversion to a digital filter: | |
this s-plane to z-plane transformation. | |
it also compensates for the frequency-warping effect. | |
b and a are the normalized numerator and denominator coefficients (respectively) | |
of the polynomial in the s-plane, and w0 is the angular center frequency. | |
effectively, w0 = 2 * pi * fc / fs. | |
the modified bilinear transform is specified by RBJ [1] as: | |
s := (1 - z^-1) / (1 + z^-1) / tan(w0 / 2) | |
as opposed to the traditional bilinear transform: | |
s := (1 - z^-1) / (1 + z^-1) | |
[1]: http://musicdsp.org/files/Audio-EQ-Cookbook.txt | |
""" | |
# in my opinion, this is a more convenient way of creating digital filters. | |
# scipy doesn't implement the modified bilinear transform, | |
# so i'll just write the expanded form for second-order filters here. | |
# TODO: include the equation that expanded/simplified to this | |
# use padding to allow for first-order filters. | |
# untested | |
if len(b) == 2: | |
b = (b[0], b[1], 0) | |
if len(a) == 2: | |
a = (a[0], a[1], 0) | |
assert len(b) == 3 and len(a) == 3, "unsupported order of filter" | |
cw = np.cos(w0) | |
sw = np.sin(w0) | |
zb = np.array(( | |
b[0]*(1 - cw) + b[2]*(1 + cw) - b[1]*sw, | |
2*(b[0]*(1 - cw) - b[2]*(1 + cw)), | |
b[0]*(1 - cw) + b[2]*(1 + cw) + b[1]*sw, | |
)) | |
za = np.array(( | |
a[0]*(1 - cw) + a[2]*(1 + cw) - a[1]*sw, | |
2*(a[0]*(1 - cw) - a[2]*(1 + cw)), | |
a[0]*(1 - cw) + a[2]*(1 + cw) + a[1]*sw, | |
)) | |
return zb, za | |
def digital_magnitude(xs, cascade, fc, fs, decibels=True): | |
""" | |
compute the digital magnitude response | |
of an analog SOS filter (cascade) | |
at the points given at xs | |
and the center frequency fc | |
for the sampling rate of fs. | |
""" | |
to_magnitude_decibels = lambda x: np.real(np.log10(np.abs(x)**2) * 10) | |
# compute angular frequencies | |
ws = 2 * np.pi * xs / fs | |
w0 = 2 * np.pi * fc / fs | |
digital_cascade = [modified_bilinear(*ba, w0=w0) for ba in cascade] | |
# we don't need the first result freqz returns | |
# because it'll just be the worN we're passing. | |
responses = [sig.freqz(*ba, worN=ws)[1] for ba in digital_cascade] | |
if decibels: | |
mags = [to_magnitude_decibels(response) for response in responses] | |
mags = np.array(mags) | |
composite = np.sum(mags, axis=0) | |
else: | |
# untested | |
mags = [np.abs(response) for response in responses] | |
mags = np.array(mags) | |
composite = np.prod(mags, axis=0) | |
# we could also compute phase using (untested): | |
# -np.arctan2(np.imag(response), np.real(response)) | |
return composite, mags | |
# compute the butterworth coefficients for later. | |
# we'll request them in SOS format: second-order sections. | |
# that means we'll get an array of second-order filters | |
# that combine to produce one higher-order filter. | |
# note that we specify 1 as the frequency; we'll give the actual frequency | |
# later, when we transform it to a digital filter. | |
butterworth_2 = sig.butter(N=2, Wn=1, analog=True, output='sos') | |
butterworth_6 = sig.butter(N=6, Wn=1, analog=True, output='sos') | |
# scipy returns the b and a coefficients in a flat array, | |
# but we need them separate. | |
butterworth_2 = butterworth_2.reshape(-1, 2, 3) | |
butterworth_6 = butterworth_6.reshape(-1, 2, 3) | |
butterworth_2_times3 = butterworth_2.repeat(3, axis=0) | |
# set up our first plot. | |
fig, ax = plt.subplots() | |
response_setup(ax, -30, 3) | |
ax.set_title("comparison of digital butterworth filters (fc=1kHz, fs=44.1kHz)") | |
# compute the magnitude of the filters at our x positions. | |
# we don't want the individual magnitudes here, | |
# so assign them to the dummy variable _. | |
ys1, _ = digital_magnitude(xs, butterworth_2, fc, fs) | |
ys2, _ = digital_magnitude(xs, butterworth_2_times3, fc, fs) | |
ys3, _ = digital_magnitude(xs, butterworth_6, fc, fs) | |
# our x axis is frequency, so it should be spaced logarithmically. | |
# our y axis is decibels, which is already a logarithmic unit. | |
# thus, semilogx is the plotting function to use here. | |
ax.semilogx(xs, ys1, label="second order") | |
ax.semilogx(xs, ys2, label="second order, three times") | |
ax.semilogx(xs, ys3, label="sixth order") | |
ax.legend() # display the legend, given by labels when plotting. | |
fig.show() | |
fig.savefig('butterworth_comparison.png') | |
# set up our second plot. | |
fig, ax = plt.subplots() | |
response_setup(ax, -12, 12) | |
ax.set_title("digital butterworth decomposition (fc=1kHz, fs=44.1kHz)") | |
cascade = butterworth_6 | |
composite, mags = digital_magnitude(xs, cascade, fc, fs) | |
for i, mag in enumerate(mags): | |
# Q has an inverse relation to the 1st-degree coefficient | |
# of the analog filter's denominator, so we can compute it that way. | |
Q = 1 / cascade[i][1][1] | |
ax.semilogx(xs, mag, label="second order (Q = {:.6f})".format(Q)) | |
ax.semilogx(xs, composite, label="composite") | |
ax.legend() | |
fig.show() | |
fig.savefig('butterworth_composite.png') | |
# as a bonus, here's how the modified bilinear transform is especially useful: | |
# we can specify all the basic second-order analog filter types | |
# with minimal code! | |
lowpass2 = lambda A, Q: ((1, 0, 0), | |
(1, 1/Q, 1)) | |
highpass2 = lambda A, Q: ((0, 0, 1), | |
(1, 1/Q, 1)) | |
allpass2 = lambda A, Q: ((1, 1/Q, 1), | |
(1, 1/Q, 1)) | |
# peaking filters are also (perhaps for the better) known as bell filters. | |
peaking2 = lambda A, Q: ((1, A/Q, 1), | |
(1, 1/A/Q, 1)) | |
# TODO: add classical "analog" peaking filter, where bandwidth and amplitude are interlinked | |
# there are two common bandpass variants: | |
# one where the bandwidth and amplitude are interlinked, | |
bandpass2a = lambda A, Q: ((0, -A/Q, 0), | |
(1, 1/A/Q, 1)) | |
# and one where they aren't. | |
bandpass2b = lambda A, Q: ((0,-A*A/Q, 0), | |
(1, 1/Q, 1)) | |
notch2 = lambda A, Q: ((1, 0, 1), | |
(1, 1/Q, 1)) | |
lowshelf2 = lambda A, Q: (( A, np.sqrt(A)/Q, 1), | |
(1/A, 1/np.sqrt(A)/Q, 1)) | |
highshelf2 = lambda A, Q: ((1, np.sqrt(A)/Q, A), | |
(1, 1/np.sqrt(A)/Q, 1/A)) | |
# here's an example of how to plot these. | |
# we specify the gain in decibels and bandwidth in octaves, | |
# then convert these to A and Q. | |
fig, ax = plt.subplots() | |
response_setup(ax, -30, 30) | |
invsqrt2 = 1 / np.sqrt(2) # for bandwidth <-> Q calculation | |
# note that A is squared, so the decibel math here is a little different. | |
designer = lambda f, decibels, bandwidth: f(10**(decibels / 40.), invsqrt2 / bandwidth) | |
ax.set_title("example of interlinked bandpass amplitude/bandwidth") | |
cascade = [ | |
designer(bandpass2a, 0, 2), | |
designer(bandpass2a, -18, 2), | |
designer(bandpass2a, 18, 2), | |
] | |
composite, mags = digital_magnitude(xs, cascade, fc, fs) | |
for mag in mags: | |
ax.semilogx(xs, mag) | |
#ax.semilogx(xs, composite) | |
#ax.legend() | |
fig.show() | |
fig.savefig('filter_example.png') |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment