Reverberation the space between notes
How a handful of feedback delays and allpass filters — the comb-and-allpass reverberator of Schroeder, Moorer and Jezar's Freeverb — synthesise a room out of nothing but arithmetic.
How a handful of feedback delays and allpass filters — the comb-and-allpass reverberator of Schroeder, Moorer and Jezar's Freeverb — synthesise a room out of nothing but arithmetic.
Strike a note in a cathedral and it does not stop when your hand leaves the string. Sound leaves the source, strikes stone, floor and vault, and returns as thousands of delayed, attenuated copies of itself — first as sparse, distinct early reflections that tell the ear the size and shape of the room, then as a dense, exponentially decaying wash of energy so thick that the individual echoes fuse into a single continuous tail. That tail is reverberation, and its length, colour and density are as much a part of recorded music as the instruments themselves.
Reverberation is a linear, time-invariant process: the room is completely described by its impulse response — the sound you would hear from a single, infinitely short click. Convolve any signal with that impulse response and you hear it as if played in the room. A perfect reverberator, then, is simply a filter with a very long, very dense impulse response. The engineering problem is that a real room's impulse response is seconds long: at 48 kHz that is hundreds of thousands of taps, far too many to compute directly one multiply at a time — at least it was in 1961, when Manfred Schroeder first asked whether a room could be faked rather than measured.
His answer, and the answer that still runs inside most mixing desks and plug-ins, is beautiful: do not store the impulse response at all. Instead build a small network of recursive delays — feedback comb filters that manufacture an exponentially decaying train of echoes, and allpass filters that smear those echoes in time without colouring their spectrum — and let the feedback do the work that hundreds of thousands of taps would otherwise do. A dozen delay lines, a handful of multiplies per sample, and a convincing hall appears. This page derives that network from first principles, builds a faithful Freeverb in your browser out of eight comb filters and four allpasses per channel, and measures its impulse response live so you can watch the tail it invents.
AudioWorklet — the C++ and JavaScript you read below is the code you hear.Before reverberation could be computed it had to be built. The story runs from literal rooms, through vibrating metal, to the recursive filters that made a hall cost a few kilobytes of delay memory.
dsp::Reverb.The atom of algorithmic reverb is a delay line fed back on itself. Take the input, add a delayed and attenuated copy of the output, and you have the feedback comb filter:
Here $D$ is the delay in samples and $g$ (with $|g|<1$) the feedback gain. A single impulse in produces an impulse out at $n=0$, then a copy of gain $g$ at $n=D$, $g^2$ at $2D$, $g^3$ at $3D$ — a geometric train of echoes spaced $D$ samples apart. Taking the $z$-transform of both sides, $Y(z) = X(z) + g\,z^{-D}Y(z)$, gives the transfer function
The poles sit where $z^{D} = g$, i.e. at $D$ equally spaced radii $|z| = g^{1/D}$ — a ring of resonances that, in the magnitude response, look like the teeth of a comb (hence the name), spaced $f_s/D$ hertz apart. A single comb on its own sounds metallic and hollow, precisely because those teeth are so regular: it rings on a pitch of $f_s/D$.
The echoes decay by a factor $g$ every $D$ samples. The reverberation time RT60 is, by definition, the time for the level to fall by 60 dB, i.e. by a factor of $10^{-3}$ (since $-60\,\mathrm{dB} = 20\log_{10}10^{-3}$). In $RT_{60}$ seconds there are $RT_{60}f_s/D$ round trips, so we require $g^{\,RT_{60}f_s/D} = 10^{-3}$, which rearranges to the gain we must dial in for a target decay:
Note the crucial consequence: a longer delay $D$ needs a higher gain for the same RT60, and different comb lengths reach 60 dB of decay at exactly the same time — which is what lets several combs of different lengths share a single, coherent decay.
Combs give decay but colour the sound. Schroeder's second building block gives density without colour. The allpass filter combines a feedforward and a feedback path around the same delay:
Its transfer function is
The magic is in the numerator and denominator: the numerator's coefficients are the denominator's, reversed. Evaluate on the unit circle $z=e^{j\omega}$ and the numerator is the complex conjugate of the denominator times $z^{-D}$, so their magnitudes are identical and
The filter passes every frequency at unity gain — hence "allpass" — but its phase, and therefore its group delay, varies with frequency. An impulse fed in comes out not as a single spike but as a dense, dispersed cluster of echoes: the allpass multiplies echo density without adding any spectral colouration. Schroeder's insight was to place several allpasses in series after the combs, each one exploding every echo into a burst, so that echo density grows multiplicatively toward the thousands-per-second the ear expects of a real room.
Freeverb, written around 2000 by "Jezar at Dreampoint" and released to the public domain, is the canonical modern Schroeder reverb. Per channel it is eight feedback comb filters in parallel, their outputs summed, followed by four allpass filters in series. Two refinements over the 1962 original make it sound like a room rather than a machine.
In a real hall, high frequencies are absorbed faster than low ones — by the air, by soft furnishings, by the audience — so the tail grows darker as it decays. Moorer's fix, adopted by Freeverb, is to put a one-pole low-pass filter inside the comb's feedback loop. Each pass round the delay loses a little more treble:
Here $d$ is the damping coefficient ($0\le d<1$). At $d=0$ the comb is the plain recursion of §3; as $d\to1$ the feedback path becomes an integrator and high frequencies are stripped away within a few round trips, giving a warm, fast-decaying top end over a longer low-frequency tail. Because the low-pass is recursive and nested in the loop, its effect compounds — precisely mimicking cumulative air and boundary absorption.
The eight combs use mutually incommensurate delay lengths (Freeverb's classic tunings, in samples at 44.1 kHz, are 1116, 1188, 1277, 1356, 1422, 1491, 1557 and 1617). Because their comb teeth fall at different frequencies, summing them fills in the gaps and cancels the metallic single-comb pitch, while the shared $g$ keeps their decays aligned to one RT60. The sum then passes through four series allpasses (delays 556, 441, 341, 225 samples, feedback $g=0.5$) which multiply echo density into a smooth diffuse tail.
To decorrelate the two channels — the key to a wide, enveloping image — Freeverb runs a second bank of combs and allpasses for the right channel whose delay lengths are all longer by a fixed stereospread of 23 samples. Same algorithm, slightly different room, and the two ears hear subtly different tails. A width control then cross-blends the two channels.
Sets the comb feedback $g$ (Freeverb: $g = 0.7 + 0.28\,\text{size}$). Higher → longer RT60.
Sets $d$ (Freeverb: $d = 0.4\,\text{damp}$). Higher → darker, faster-decaying tail.
Cross-mix of the two channels' wet signals; 1 = fully stereo, 0 = mono.
The comb-and-allpass network is efficient and musical but structurally simple, and its echo pattern can sound a little regular on transient-rich material. Two later approaches address realism from opposite directions.
Rather than isolated combs, an FDN couples $N$ delay lines through a single $N\times N$ feedback matrix $\mathbf{A}$. With delay-line states $\mathbf{s}$, input vector $\mathbf{b}$ and output vector $\mathbf{c}$:
If $\mathbf{A}$ is unitary (lossless) the network conserves energy and never colours the sound; decay is then introduced deliberately by attaching a per-line attenuation $g_i = 10^{-3 m_i/(RT_{60}f_s)}$ — the same RT60 law as §3, now applied to each of $N$ lines. Stautner & Puckette (1982) first proposed the matrix-coupled structure; Jot & Chaigne (1991) showed how to choose $\mathbf{A}$ and the attenuations for an exact, frequency-dependent RT60, giving the smooth, dense, controllable tails of high-end reverbs. A Householder or Hadamard matrix is a common lossless choice, mixing every delay line into every other on each pass and multiplying density far faster than series allpasses alone.
The opposite philosophy: abandon synthesis and use a measured impulse response of a real space. The output is the exact convolution $y[n] = \sum_k h[k]\,x[n-k]$. Done directly this is ruinously expensive for a multi-second $h$, so convolution reverbs use partitioned overlap-add FFT convolution (Gardner's low-latency scheme): the impulse response is split into blocks, each transformed once, and the running convolution becomes cheap frequency-domain multiplies. The result is indistinguishable from the sampled room — but that room is frozen. You cannot lengthen its decay, move its walls or modulate it without artefacts, which is exactly what the algorithmic FDN and Freeverb designs let you do freely.
| Approach | Realism | Cost | Adjustable? |
|---|---|---|---|
| Schroeder / Freeverb | Good, can flutter | Very low | Fully (size, damp, width) |
| FDN (Jot) | Excellent, dense | Low–moderate | Fully, incl. per-band RT60 |
| Convolution | Exact (it is a real room) | High (FFT) | Barely — the room is fixed |
This reverb is real. Eight parallel damped combs and four series allpasses per channel — Freeverb's exact delay tunings, scaled to your device's sample rate — run sample-by-sample inside an AudioWorklet. Nothing here is a native ConvolverNode: the code in §09 is the code you are hearing.
Loop = a synthesised marimba phrase played with gaps so the tail is audible · Mic = live input (allow permission) · File = drag-drop or click "File" to load your own · A/B compares the wet reverb against the dry input · Freeze holds the tail indefinitely.
Three views into the running network. The first two are computed from the same comb/allpass arithmetic as the live demo, re-run offline on a single impulse whenever you move a knob; the third animates the signal flow through the topology.
Feed a single sample of value 1 into the reverb and record what comes back: the impulse response. Its exponential envelope is the reverb tail. Adjust Room Size or Damping in the demo above and watch the tail lengthen or darken here in real time.
Schroeder's own 1965 method for measuring RT60: integrate the squared impulse response backwards in time to get a smooth decay curve, $\mathrm{EDC}(t)=\int_t^{\infty} h^2(\tau)\,d\tau$, and read the slope in decibels. The dashed line marks the −60 dB point — the estimated RT60 of the network as currently tuned.
Signal enters, fans out to the eight parallel combs (each a delay with a damped feedback loop), sums, then passes down the four series allpasses to the output. Particles trace the flow.
JUCE ships a ready reverb — juce::dsp::Reverb — which is itself a Freeverb port, so the mapping to the JavaScript above is exact. Its Parameters struct exposes the same controls.
#include <juce_dsp/juce_dsp.h>
class ReverbProcessor
{
public:
void prepare (const juce::dsp::ProcessSpec& spec)
{
reverb.prepare (spec); // sample rate, block size, channels
reverb.setParameters (params);
}
void setRoomSize (float s) { params.roomSize = s; reverb.setParameters (params); }
void setDamping (float d) { params.damping = d; reverb.setParameters (params); }
void setWidth (float w) { params.width = w; reverb.setParameters (params); }
void setMix (float m) { params.wetLevel = m;
params.dryLevel = 1.0f - m;
reverb.setParameters (params); }
void setFreeze (bool f) { params.freezeMode = f ? 1.0f : 0.0f;
reverb.setParameters (params); }
void process (juce::AudioBuffer<float>& buffer)
{
juce::dsp::AudioBlock<float> block (buffer);
juce::dsp::ProcessContextReplacing<float> ctx (block);
reverb.process (ctx); // stereo Freeverb in place
}
private:
juce::dsp::Reverb reverb;
juce::dsp::Reverb::Parameters params { /*roomSize*/ 0.72f, /*damping*/ 0.5f,
/*wetLevel*/ 0.35f, /*dryLevel*/ 0.65f,
/*width*/ 1.0f, /*freezeMode*/ 0.0f };
};
juce::dsp::Reverb wraps the Freeverb comb/allpass network — the Parameters map one-to-one onto the knobs in §07.
To show the arithmetic explicitly — and to map directly onto the JavaScript worklet — here is a hand-rolled damped comb and Schroeder allpass, the two primitives Freeverb is built from:
// One damped feedback comb: y[n] = x[n] + g * lowpass(y[n-D])
struct Comb
{
std::vector<float> buf;
int idx = 0;
float store = 0.0f; // one-pole low-pass state (damping)
float feedback = 0.84f; // g = 0.7 + 0.28 * roomSize
float damp1 = 0.2f; // d = 0.4 * damping
float damp2 = 0.8f; // 1 - damp1
void setDamp (float d) { damp1 = d; damp2 = 1.0f - d; }
void resize (int n) { buf.assign ((size_t) n, 0.0f); idx = 0; store = 0.0f; }
inline float process (float x) noexcept
{
float y = buf[(size_t) idx];
store = y * damp2 + store * damp1; // low-pass in the loop
buf[(size_t) idx] = x + store * feedback; // write back with feedback
if (++idx >= (int) buf.size()) idx = 0;
return y;
}
};
// One Schroeder allpass: y[n] = -g*x[n] + x[n-D] + g*y[n-D]
struct Allpass
{
std::vector<float> buf;
int idx = 0;
float feedback = 0.5f;
void resize (int n) { buf.assign ((size_t) n, 0.0f); idx = 0; }
inline float process (float x) noexcept
{
float bufout = buf[(size_t) idx];
float y = -x + bufout; // magnitude-flat output
buf[(size_t) idx] = x + bufout * feedback;
if (++idx >= (int) buf.size()) idx = 0;
return y;
}
};
// Freeverb's 44.1 kHz tunings (scale by sampleRate/44100 at prepare()):
static constexpr int combTuning[8] = { 1116,1188,1277,1356,1422,1491,1557,1617 };
static constexpr int allpassTuning[4] = { 556, 441, 341, 225 };
static constexpr int stereoSpread = 23; // added to the right channel's delays
The hand-rolled primitives — identical maths to the JavaScript AudioWorklet driving the demo above.
For sampled-space (convolution) reverb, JUCE provides juce::dsp::Convolution, which loads an impulse-response file and performs partitioned, zero-latency FFT convolution:
juce::dsp::Convolution convolution;
void prepareIR (const juce::dsp::ProcessSpec& spec)
{
convolution.prepare (spec);
convolution.loadImpulseResponse (BinaryData::hall_wav, BinaryData::hall_wavSize,
juce::dsp::Convolution::Stereo::yes,
juce::dsp::Convolution::Trim::yes, 0);
}
// convolution.process (ctx); // exact reproduction of the sampled room
| Element | Role | Magnitude response | Difference equation |
|---|---|---|---|
| Feedback comb | Exponential echo train (the decay) | Peaky "comb teeth" | $y=x+g\,y[n{-}D]$ |
| Damped comb | Decay with treble loss (Moorer) | Comb teeth, rolled-off top | $y=x+g\!\cdot\!\text{LP}(y[n{-}D])$ |
| Schroeder allpass | Echo density / diffusion | Flat (unity) | $y=-gx+x[n{-}D]+g\,y[n{-}D]$ |
| FDN | Dense, coupled, tunable tail | Flat if $\mathbf A$ unitary | $\mathbf s(n{+}\mathbf m)=\mathbf A\mathbf s+\mathbf b x$ |
| Convolution | Exact sampled room | The room's own | $y=\sum_k h[k]x[n{-}k]$ |
dsp::Reverb).juce::dsp::Reverb and juce::dsp::Convolution class documentation. juce.com