What I Learned Building a Parametric EQ in Python
A practical breakdown of filter design, Q-factor tradeoffs, and audio processing pitfalls I ran into while building an EQ from scratch.
Why I Built It
I wanted to understand how EQ behavior is actually created in code instead of treating plugins like a black box.
Building a parametric EQ manually forced me to think in terms of center frequency, bandwidth, gain, and how those parameters affect real audio data sample-by-sample.
Big Technical Lessons
- Second-order-section filters are safer for audio work than direct-form coefficients because they are more numerically stable.
- Q controls bandwidth in a non-intuitive way at first; visualizing low Q vs high Q behavior helped me tune filter ranges faster.
- Splitting the signal into low-pass, band-pass, and high-pass regions made it easier to reason about what was being boosted or cut.
- Sample rate awareness is critical: every cutoff and design parameter must be normalized correctly or results will sound wrong.
- Stereo-safe processing means always filtering across axis 0 carefully and preserving channel structure.
def high_pass_filter(data, cutoff_frequency, sample_rate, order=5):
sos = butter(order, cutoff_frequency / (sample_rate / 2),
btype='high', output='sos')
filtered_data = sosfilt(sos, data, axis=0)
return filtered_datadef convert_to_q(crit_freq, q, range=[1,20000]):
half_bandwidth = ((crit_freq/q)/2)
a = crit_freq - half_bandwidth
b = crit_freq + half_bandwidth
# Make sure it does not go out of bounds of audio perceivable sound
if a < range[0]:
a = range[0]
return [a, b]The Data Type Problem
Most DSP operations run in floating point, but WAV files are often int16. If I wrote float output directly without conversion, playback broke or clipped unpredictably.
The reliable flow was: process in float, verify levels, then cast back to the original dtype before writing the file.
def parametric_eq(data, center_frequency, Q, gain, sample_rate):
# Define the bandwidth for boosting/cutting
bandwidth = convert_to_q(center_frequency, Q)
print(f"This is the bandwidth range: {bandwidth}")
# Bandpass filter to be affected
sos_bandpass = iirfilter(N=2, Wn=bandwidth, btype='band', ftype='butter', analog=False, fs=sample_rate, output='sos')
processed_data_bandpass = sosfilt(sos_bandpass, data, axis=0)
processed_data_bandpass *= 10**(gain / 20.0)
# Design high-pass filter for frequencies below the specified range
sos_highpass = iirfilter(N=1, Wn=bandwidth[1], btype='high', ftype='butter', analog=False, fs=sample_rate, output='sos')
processed_data_highpass = sosfilt(sos_highpass, data, axis=0)
# Design low-pass filter for frequencies above the specified range
sos_lowpass = iirfilter(N=1, Wn=bandwidth[0], btype='low', ftype='butter', analog=False, fs=sample_rate, output='sos')
processed_data_lowpass = sosfilt(sos_lowpass, data, axis=0)
# Return the summation of all processed segments
return processed_data_highpass + processed_data_lowpass + processed_data_bandpassprint(f"This is the old dtype of new_audio: {new_audio.dtype}")
print(f"This is the dtype of audio_data: {audio_data.dtype}")
# Cast the processed data to the original data type
new_audio = new_audio.astype(audio_data.dtype)
print(f"This is the new dtype of new_audio: {new_audio.dtype}")
wavfile.write('testing_output.wav', rate, new_audio)What I Would Improve Next
- Add output gain compensation and limiter-style safety to reduce clipping risk after boosts.
- Graph the frequency response so tuning center frequency and Q is faster than trial-and-error listening.
- Expand to multi-band EQ with preset support for voice cleanup, music shaping, and live playback contexts.
- Package the code into a reusable module/CLI so it can process folders of files in one run.
Takeaway
The biggest win was confidence: after building it from scratch, I understand what EQ parameters are really doing and can make better engineering decisions in both software and live audio workflows.