In [1]:
import numpy as np
import audio_dspy as adsp
import scipy.signal as signal
import matplotlib.pyplot as plt
plt.style.use('dark_background')

Extensions on Adaptive Equalization

In this article we'll be examining some interesting uses of adaptive filtering for audio equalization, and how it can be extended with frequency warping and nonlinearities. The resulting effect can be used for setting an EQ filter that makes one instrument sound optimally similar to another, or to create an interesting effect that "copies" the frequencies from one sound onto another.

Adaptive Equalization

First let's examine the basic mechanism for Adative EQing: the LMS adaptive filter. An adaptive filter is simplyy a filter that takes in an input and a desired output, and adjusts its filter shape in real-time to produce an output that is optimally similar to the desired output. While there are several methods for performing the optimization step, such as Least Mean Squared (LMS), Normalized Least Mean Squared (NLMS), and Recursive Least Square (RLS), for this use case, we prefer LMS. The reason for choosing LMS is that NLMS doesn't take into account the amplitude of the input signal when adjusting the weights thereby causing the filter to react too much to low-level signals, and RLS doesn't do a good job of "forgetting" previous samples, making it react poorly for rapidly changing signals like we often find in audio.

Below we show a simple example of using an adaptive EQ to approximate a pure sine wave from a white noise signal.

In [2]:
fs = 44100
N = int(0.5 * 44100)
freq = 8000

desired = np.sin(2 * np.pi * np.arange(N) * freq / fs)

input = np.random.uniform(-1.0, 1.0, N)

output, w, e = adsp.LMS(input, desired, 0.001, 128)
In [3]:
def plot_specgram(sig, title):
    plt.figure()
    f, t, Zxx = signal.stft(sig, fs=fs, nperseg=256, nfft=4096)
    plt.imshow(20 * np.log10(np.abs(Zxx)), cmap='inferno', origin='lower',
               aspect='auto', extent=[np.min(t), np.max(t), np.min(f), np.max(f)])
    plt.title(title)
    plt.xlabel('Time [s]')
    plt.ylabel('Frequency [Hz]')

plot_specgram(desired, 'Desired Signal')
plot_specgram(input, 'Input Signal')
plot_specgram(output, 'Output Signal')

So that's pretty cool! DSP engineers use this technique often for signal "prediction" where they can use white noise to approximate any desired signal. The reason why white noise works well for this is that it has frequency content at all frequencies, while your average audio signal may not. However, because adaptive filters are time varying, they can actually shift frequencies as well. As an example, we can use our adaptive filtering algorithm to filter a sine wave at one frequency to predict a sine wave at another.

In [4]:
freq1 = 5000
freq2 = 10000

desired = np.sin(2 * np.pi * np.arange(N) * freq2 / fs)
input = np.sin(2 * np.pi * np.arange(N) * freq1 / fs)
output, w, e = adsp.LMS(input, desired, 0.001, 128)

plot_specgram(desired, 'Desired Signal')
plot_specgram(input, 'Input Signal')
plot_specgram(output, 'Output Signal')

So that works pretty well. But what if there was a way to make our signal a bit more broadband, a bit more like white noise, without losing it's amplitude envelope, or overall melody and harmony.

Nonlinearities

The solution to this problem can come from putting our signal through a nonlinear function before putting it through the adaptive filter, since the nonlinear function can generate more frequencies.

In [5]:
freq1 = 1000
freq2 = 9000

desired = np.sin(2 * np.pi * np.arange(N) * freq2 / fs)
input0 = np.sin(2 * np.pi * np.arange(N) * freq1 / fs)
input1 = adsp.soft_clipper(input0, deg=7)
output, filt, e = adsp.LMS(input1, desired, 0.01, 128)

plot_specgram(desired, 'Desired Signal')
plot_specgram(input0, 'Input Signal')
plot_specgram(input1, 'Input Signal after nonlinearity')
plot_specgram(output, 'Output Signal')