The Parametric Equaliser
From the bilinear transform to a live, hand-written biquad cascade you can drag with your fingers. The RBJ Audio-EQ Cookbook, rendered honestly in the browser.
From the bilinear transform to a live, hand-written biquad cascade you can drag with your fingers. The RBJ Audio-EQ Cookbook, rendered honestly in the browser.
An equaliser is a bank of linear, time-invariant filters placed in series across an audio signal. Each filter reshapes the spectrum in one region — a boost here, a cut there, a slope rolled off at the extremes — without inventing anything that was not already present. Everything an EQ does can be written as a single transfer function, and almost every knob you have ever turned on a channel strip is, underneath, one second-order section: a biquad.
The word "equalise" is a historical accident. Early telephone and disc-cutting engineers used compensating networks to equalise the frequency-dependent losses of long cable runs and the RIAA recording curve — to make the output flat again. Musicians inherited the tool and inverted the intent: we now reach for an EQ precisely to make things un-flat, to carve space in a mix, to tame a resonant room mode, or to flatter a voice. The mathematics did not change; only the aesthetics did.
This page builds a full parametric equaliser — one where frequency, gain and bandwidth are all continuously adjustable per band — from first principles. We derive the biquad from the analogue prototype via the bilinear transform, quote Robert Bristow-Johnson's celebrated cookbook coefficient formulas, and then run those exact formulas in a hand-written AudioWorklet so that the equation printed on the page is quite literally the code processing the sound. You can drag six bands across a live spectrum and hear every move.
A biquad ("bi-quadratic") is a filter whose transfer function is the ratio of two quadratics in the delay operator $z^{-1}$. In the $z$-domain,
Realised as a Direct Form I difference equation — the most transparent arrangement, with the two feed-forward delays and two feedback delays kept separate — a normalised biquad computes each output sample as
Two multiply-accumulates on past inputs, two on past outputs, five coefficients, four state registers. That is the entire cost of one EQ band, and it is why a modern console can afford dozens of them per channel.
The recipe starts in the continuous-time world, where a second-order section has a Laplace transfer function $H(s)$ built from an analogue prototype — a resonant RLC network, or its op-amp equivalent. To move it into a sampled system we need a map from the $s$-plane to the $z$-plane that (a) is algebraically simple and (b) keeps a stable analogue filter stable. The bilinear transform does exactly this:
It maps the entire imaginary $s$-axis (all analogue frequencies $0\!\to\!\infty$) onto the unit circle in the $z$-plane exactly once, and the whole open left half-plane to the interior of the unit circle — so stability is preserved. Substituting this into $H(s)$ and clearing denominators always yields a ratio of quadratics in $z^{-1}$: a biquad. The price is frequency warping: the analogue frequency $\Omega$ and the digital frequency $\omega$ are related by $\Omega=\frac{2}{T}\tan(\omega/2)$. The cookbook formulas absorb this warp by pre-warping the design frequency, which is why they are written directly in terms of the digital centre frequency rather than an analogue one.
In the late 1990s Robert Bristow-Johnson posted a single plain-text document to the music-dsp mailing list that has since become the most-cited page in practical audio DSP. It gives, for each classic response, the five biquad coefficients as closed-form expressions of three shared intermediate quantities. Define
where $f_0$ is the centre or corner frequency, $Q$ the quality factor (bandwidth), $G$ the gain in decibels for peaking and shelving types, and $A$ therefore the amplitude gain. Note the divide-by-40, not 20: peaking and shelf filters place $A$ on both the numerator and denominator, so the realised gain is $A^2$ in power terms, i.e. $G$ dB. Below, $a_0$ is the normalising denominator — divide every coefficient by it before use.
The symmetry between numerator and denominator is what makes a bell boost and its matching cut exact mirror images: swap $A\leftrightarrow 1/A$ and the roles of the two quadratics exchange.
These have no gain parameter — they either pass or reject — so $A$ drops out and the numerator carries the whole shape:
with the common denominator $a=[\,1+\alpha,\;-2\cos\omega_0,\;1-\alpha\,]$ for all three. The high-pass is the workhorse of every mix: a gentle $Q\approx0.7$ (Butterworth) roll-off from 20–40 Hz removes subsonic rumble and inter-track mud that a single-band section handles for almost no CPU.
| Type | Params used | Typical use |
|---|---|---|
| Peaking | f, gain, Q | Surgical cut or musical boost of a resonance |
| Low shelf | f, gain, Q | Overall weight / warmth below a corner |
| High shelf | f, gain, Q | "Air", brightness above a corner |
| High-pass | f, Q | Rumble / DC removal, low-end clean-up |
| Low-pass | f, Q | De-essing send, taming harshness, effects |
| Notch | f, Q | Killing a single tone: mains hum, feedback |
Coefficients are not a spectrum. To see what a biquad does we evaluate its transfer function on the unit circle, where $z=e^{j\omega}$ and $\omega=2\pi f/f_s$ runs from $0$ (DC) to $\pi$ (Nyquist). The complex frequency response is
The magnitude response $|H(e^{j\omega})|$ — how much each frequency is boosted or cut — is the modulus of that ratio, usually plotted in decibels as $20\log_{10}|H|$. The phase response $\arg H(e^{j\omega})$ is its argument, and its negative derivative is the group delay. A minimum-phase biquad like these couples the two: you cannot change the magnitude without incurring the phase shift that a causal, stable filter obliges. Only a linear-phase (FIR) EQ escapes it, at the cost of latency and pre-ring.
Evaluating the modulus is just book-keeping with a complex exponential. Writing $c=\cos\omega$, $s=\sin\omega$ and stepping to the second harmonic with $\cos 2\omega$, $\sin 2\omega$, the numerator's real and imaginary parts are $\Re=b_0+b_1 c+b_2\cos 2\omega$ and $\Im=-(b_1 s+b_2\sin 2\omega)$, and similarly for the denominator; the magnitude is the ratio of the two hypotenuses. This is precisely the function evalBiquad() that draws the green curve in the demo below, and cascading bands means multiplying their complex responses (adding their dB curves and their phases).
Everything here runs on hand-written biquads. Six bands — a high-pass, a low shelf, three bells and a high shelf — are cascaded inside an AudioWorklet executing the Direct Form I difference equation sample by sample. The green curve is the product of the six transfer functions evaluated on the unit circle; the ghostly fill behind it is a live FFT of the output. Drag the numbered nodes, or use the per-band strips, and listen.
Drag a node horizontally to sweep frequency, vertically to change gain (for bells and shelves) or Q (for the high-pass, low-pass and notch). The mouse wheel over a node adjusts its Q; double-clicking resets that band. On a phone, drag with one finger. The built-in loop synthesises a kick, a snare, a bass line and a sustained chord pad so that moves in every region are audible.
The main display sums the magnitude of all six bands. Two further canvases isolate the pieces. First, the phase response of the same cascade, wrapped to $\pm180^\circ$ — watch how a bell boost drags the phase one way on its low side and the other on its high side, the unavoidable signature of a minimum-phase filter:
Second, a self-contained single-biquad explorer. This one makes no sound; it simply draws $20\log_{10}|H|$ for one filter so you can see, in isolation, how frequency, gain and $Q$ deform a single second-order section. Raising $Q$ narrows a bell and steepens a shelf's overshoot; the coefficient formulas of §3 are doing all of it.
The browser demo hand-rolls the difference equation for pedagogy. In a shipping plug-in you would normally lean on juce::dsp::IIR, whose Coefficients factory methods implement these very cookbook formulas. Here is a compact but idiomatic sketch: an AudioProcessorValueTreeState parameter layout for one band, a ProcessorDuplicator so a mono coefficient set runs on a stereo (or N-channel) signal, and the coefficient update in parameterChanged.
// --- PluginProcessor.h (essentials) ---
class EqAudioProcessor : public juce::AudioProcessor,
private juce::AudioProcessorValueTreeState::Listener
{
public:
using Filter = juce::dsp::IIR::Filter<float>;
using Coeffs = juce::dsp::IIR::Coefficients<float>;
using Duplicator = juce::dsp::ProcessorDuplicator<Filter, Coeffs>;
EqAudioProcessor();
void prepareToPlay (double sr, int blockSize) override;
void processBlock (juce::AudioBuffer<float>&, juce::MidiBuffer&) override;
juce::AudioProcessorValueTreeState apvts;
private:
static juce::AudioProcessorValueTreeState::ParameterLayout makeLayout();
void parameterChanged (const juce::String& id, float value) override;
void updateBand (int i);
double sampleRate = 44100.0;
juce::dsp::ProcessorChain<Duplicator, Duplicator, Duplicator,
Duplicator, Duplicator, Duplicator> chain;
};
// --- parameter layout: three params per band ---
juce::AudioProcessorValueTreeState::ParameterLayout
EqAudioProcessor::makeLayout()
{
juce::AudioProcessorValueTreeState::ParameterLayout layout;
const char* names[] = { "hp","ls","p1","p2","p3","hs" };
for (int b = 0; b < 6; ++b)
{
auto id = juce::String (names[b]);
layout.add (std::make_unique<juce::AudioParameterFloat>(
id + "_freq", "Freq " + id,
juce::NormalisableRange<float>(20.f, 20000.f, 1.f, 0.25f), // skewed = log
(float) juce::jmap (b, 0, 5, 30, 9000)));
layout.add (std::make_unique<juce::AudioParameterFloat>(
id + "_gain", "Gain " + id,
juce::NormalisableRange<float>(-24.f, 24.f, 0.1f), 0.f));
layout.add (std::make_unique<juce::AudioParameterFloat>(
id + "_q", "Q " + id,
juce::NormalisableRange<float>(0.1f, 12.f, 0.001f, 0.3f), 0.707f));
}
return layout;
}
// --- turning parameters into coefficients ---
void EqAudioProcessor::updateBand (int i)
{
const char* names[] = { "hp","ls","p1","p2","p3","hs" };
auto id = juce::String (names[i]);
const float f = apvts.getRawParameterValue (id + "_freq")->load();
const float g = apvts.getRawParameterValue (id + "_gain")->load();
const float q = apvts.getRawParameterValue (id + "_q") ->load();
const float A = juce::Decibels::decibelsToGain (g);
juce::dsp::IIR::Coefficients<float>::Ptr c;
switch (i)
{
case 0: c = Coeffs::makeHighPass (sampleRate, f, q); break;
case 1: c = Coeffs::makeLowShelf (sampleRate, f, q, A); break;
case 5: c = Coeffs::makeHighShelf (sampleRate, f, q, A); break;
default:c = Coeffs::makePeakFilter(sampleRate, f, q, A); break;
}
// route to the i-th duplicator in the chain
switch (i)
{
case 0: *chain.get<0>().state = *c; break;
case 1: *chain.get<1>().state = *c; break;
case 2: *chain.get<2>().state = *c; break;
case 3: *chain.get<3>().state = *c; break;
case 4: *chain.get<4>().state = *c; break;
case 5: *chain.get<5>().state = *c; break;
}
}
void EqAudioProcessor::processBlock (juce::AudioBuffer<float>& buffer,
juce::MidiBuffer&)
{
juce::dsp::AudioBlock<float> block (buffer);
juce::dsp::ProcessContextReplacing<float> ctx (block);
chain.process (ctx);
}
For readers who want to see that the library is doing nothing magical, here is the raw hand-coded biquad struct — the exact C++ analogue of the JavaScript that runs in the worklet. This maps one-to-one onto the Direct Form I equation of §2:
// --- a bare biquad, Direct Form I, matching the JS worklet ---
struct Biquad
{
float b0=1, b1=0, b2=0, a1=0, a2=0; // normalised (a0 == 1)
float x1=0, x2=0, y1=0, y2=0; // state
inline float process (float x) noexcept
{
const float y = b0*x + b1*x1 + b2*x2 - a1*y1 - a2*y2;
x2 = x1; x1 = x; // shift input history
y2 = y1; y1 = y; // shift output history
return y;
}
void setPeaking (double fs, double f0, double dBgain, double Q)
{
const double A = std::pow (10.0, dBgain / 40.0);
const double w0 = 2.0 * juce::MathConstants<double>::pi * f0 / fs;
const double cw = std::cos (w0), sw = std::sin (w0);
const double alpha = sw / (2.0 * Q);
const double a0 = 1.0 + alpha / A;
b0 = (float) ((1.0 + alpha * A) / a0);
b1 = (float) ((-2.0 * cw) / a0);
b2 = (float) ((1.0 - alpha * A) / a0);
a1 = (float) ((-2.0 * cw) / a0);
a2 = (float) ((1.0 - alpha / A) / a0);
}
};
Drawing the response in the editor is the same unit-circle evaluation the demo performs: for a path across the component's width, map each pixel to a log frequency, accumulate the magnitude of every band with Coefficients::getMagnitudeForFrequency, convert to decibels, and lineTo.
| Type | Control | Strengths | Trade-offs |
|---|---|---|---|
| Graphic | Fixed bands, one fader each | Fast, visual, live-sound staple | Coarse; overlapping bands interact |
| Parametric | Continuous f, gain, Q per band | Surgical and musical; the modern default | Needs skill; easy to over-process |
| Shelving | Corner + gain (broad) | Gentle tonal tilt, natural sounding | Not for isolating a single frequency |
| Minimum-phase (IIR) | Biquads — this page | Cheap, zero latency, "analogue" feel | Magnitude change drags phase with it |
| Linear-phase (FIR) | FFT / long convolution | No phase distortion; mastering-grade | Latency and pre-ring on transients |
The demo above is squarely a minimum-phase parametric EQ combining shelving and bell sections — the configuration of a classic mixing channel strip. The high-pass and shelves anchor the extremes; the three bells do the detailed carving in between.
AudioWorklet, AnalyserNode, and the processing model used here.