SD-Reverb signal path
Every stage from an input sample to an output sample written as arithmetic, with the constants, the smoothing, the sample-rate dependences and a measured check.
46 minutes read
9635 words
Contents30
This page follows a stereo signal through SD-Reverb, from the sample the host hands in to the sample the host takes back. Every operation on the way is written down as arithmetic. Each stage is described twice, first in words and then in symbols. The symbols are the specification and the words are what the symbols mean.
Everything here is derived from the source code as it stands at commit 4ec3060, dated 4 September 2026. No documentation, website text or memory of earlier versions was used. Where a number is quoted, the file it comes from is given. A handful of the predictions are checked at the end against an impulse response, rendered by the repository’s own offline tool.
The mathematics assumes only the ground covered by the Fundamentals section, and each page of it is named where it is first needed. Building a morphing reverb covers the same plugin as a route in, from what a room does to sound through to the design decisions. Reading it first is optional.
The path in outline
A reverb takes a sound and adds to it the reflections a room would have added. SD-Reverb builds those reflections from delayed copies of the input. Those copies are fed back on themselves, mixed together, and made a little quieter and a little duller on every trip round the loop. The input reaches that loop through a short chain of preparation stages, and the result is blended with the untouched input at the end.
| stage | what it does | equations |
|---|---|---|
| dry copy | keeps the input untouched for the mix at the end | (1) |
| low cut | one-pole high-pass, 20 to 500 Hz, default 80 Hz | (2) to (4) |
| pre-delay | 0 to 200 ms, fractional, glided per sample | (5) to (8) |
| level follower | produces the morph velocity scale and no audio | (9), (10) |
| early stage | 3 delay lines, feedback 0.4, mixed by a 3 by 3 reflection | (11) to (15) |
| diffusion | 8 all-pass stages per channel, blended in by the Diffusion knob | (16) to (20) |
| late field | 6 delay lines in a feedback loop | (21) to (41) |
| mixing matrix | a 6 by 6 reflection that moves along a closed loop | (42) to (48) |
| Entry Drift | a moving injection that keeps each channel’s level | (49), (50) |
| assembly | early tap, width, equal-power mix, trim | (51) to (54) |
| engine crossfade | two complete engines, swapped over 40 ms | (55), (56) |
The dry signal is scaled at the very end and nowhere else. No stage filters it, delays it or widens it. Everything upstream of the mix is the wet signal.
The two stages that do most of the work are the early stage and the late field. Both are the same kind of object, a feedback delay network. That is a set of delay lines whose outputs are mixed by a matrix and written back into the lines. The early stage is a small one, with three lines and heavy loss per trip, and it produces a handful of distinct echoes. The late field is a larger one, with six lines and small loss per trip, and it produces the dense, slowly fading wash.
Samples, blocks and chunks
A digital audio signal is a list of numbers. Each number is the air pressure at one instant, and the instants are equally spaced. At a sample rate of samples per second, consecutive numbers are of a second apart. Signals and samples explains this from the start. This page needs only two ideas. A signal is a sequence indexed by an integer , and a delay by samples means using in place of .
SD-Reverb works on two such sequences at once, a left channel
and a right channel
. The numbers are 32-bit floating point, nominally in the range
to
. The host does not deliver them one at a time but in blocks of some hundreds or thousands of samples (Reverb.cpp). Inside, the reverb cuts each block into chunks of at most 64 samples. Some things happen once per chunk, such as parameter smoothing and advancing the slow modulations. The audio arithmetic itself happens once per sample.
When the host changes a parameter in the middle of a block, the shells split the block at that sample and call the reverb twice. A change therefore lands exactly where the host asked (src/clap/Plugin.cpp). The reverb never sees this. It only ever sees a run of samples and the current parameter values.
All arithmetic on audio is done in single precision. Coefficients are computed in double precision when a parameter changes and then stored as single precision.
Notation
| symbol | meaning |
|---|---|
| sample rate, samples per second | |
| sample index, so is the value of signal at sample | |
| the input channels | |
| the input after the low cut | |
| after the pre-delay, which is what the early stage receives | |
| the early stage’s output | |
| what the late field receives, meaning the early output, diffused | |
| the late field’s output | |
| the plugin’s output | |
| the integer part of | |
| the nearest integer to | |
| the length of vector , which is | |
| the identity matrix | |
| the transpose of | |
| replace the stored value by |
Parameters are written by their panel names. All of them except Mode, Decay, Predelay, Low Cut, Morph Rate and Trim are plain numbers from 0 to 1. Decay is in seconds, Predelay in milliseconds, Low Cut in hertz and Morph Rate in hertz. Tone Tilt runs from to and Trim is in decibels. Mode is one of Hall, Chamber, Cathedral and Drift, numbered 0 to 3. The host sees some of these through a curve, and the section on parameter smoothing lists them. The reverb itself only ever receives the real units.
| parameter | default | parameter | default |
|---|---|---|---|
| Mode | Hall | Morph Rate | 0.02 Hz |
| Size | 0.50 | Morph Depth | 0.40 |
| Decay | 2.4 s | Entry Drift | 0, off |
| Predelay | 20 ms | Entry Rate | 0.45 |
| Early Level | 0.35 | Morph Adapt | 0.50 |
| Diffusion | 1.00 | Air Level | 0.30 |
| Tone Tilt | 0.00 | Air Motion | 0.30 |
| Low Cut | 80 Hz | Width | 0.80 |
| Mix | 0.30 | Trim | 0 dB |
| Enabled | on |
The front end
The source is Reverb::process in src/dsp/Reverb.cpp.
The dry copy
The first thing the reverb does with each input sample is keep a copy of it.
That copy is what dry means in the mix at the end. Nothing below touches it.
Low cut
Very low frequencies build up in a reverb and turn to mud. The wet path therefore starts with a filter that removes what lies below a chosen frequency and leaves everything above it. The dry signal keeps its low end, and only what enters the room is thinned.
The filter is a one-pole high-pass. The page on one-pole filters, shelves and all-passes builds it from the smoothing recurrence. Per channel, with a state that starts at 0,
and the same for the right channel with its own state. The state is a running average of the input that follows slow changes and ignores fast ones. Subtracting it from the input leaves the fast changes. The coefficient sets where slow ends and fast begins.
Here
is the Low Cut frequency, clamped to 20 to 500 Hz (Reverb.cpp). At the default 80 Hz and
,
. The response to a steady sine of frequency
has magnitude
which is 0 at , about at , and rises towards 1 above it. The coefficient rather than the frequency is what the parameter smoother glides.
Pre-delay
In a real hall the first reflection arrives some milliseconds after the direct sound, because the walls are further away than the source. Predelay holds the wet signal back by 0 to 200 ms before any of the reflections are built. That keeps the reverb from smearing the attack of the dry sound. The delay can be a fraction of a sample. When the control is moved the delay length glides rather than jumps, so there is no click.
The target delay in samples is for a Predelay of milliseconds, clamped to 0 to 200. The delay actually applied at sample follows it with a per-sample recurrence.
Each sample closes one two-thousandth of the distance still to go. A change therefore settles with a time constant of 2000 samples, or 41.7 ms at 48 kHz.
At
the pre-delay line is bypassed entirely and
, with the line neither read nor written. Otherwise the line is read at the fractional position
, and the new input is then written at the head. Reading between samples uses four neighbours and a
cubic Hermite curve
through them (src/dsp/InterpolatedDelayLine.hpp). With
and
the fractional part, take the four stored samples around the target position,
and evaluate the cubic from the inside out.
At this returns exactly and at it returns exactly. In between it draws a curve whose slope at each end matches the slope of the neighbouring samples. So is delayed by samples, to a close approximation, for any .1
The pre-delay’s memory is written with the low-cut signal , so the two stages compose. The early stage receives the input with its lows removed and then held back.
The level follower
The last thing the front end does is watch how loud the signal entering the reflection stages is. This changes no audio. It produces one number per chunk, the morph velocity scale
(src/dsp/AdaptiveMorphRate.hpp). The section on the trajectory uses it to slow the movement of the late field while the input is loud.
Per sample the level is the larger of the two channels’ magnitudes, . An envelope follows it with a fast attack and a slow release.
The first case is the attack, at 50 ms, and the second the release, at 1.2 s.
So is 1 in silence and falls to once the envelope reaches , which is about dBFS. The value used for the chunk is the one computed at its last sample. With Morph Adapt at 0 the scale is exactly 1 whatever the input, which is the constant-rate morphing guarantee the tests pin.
The early reflections stage
The source is src/dsp/EarlyReflections.cpp.
The first reflections off the nearest walls arrive as a few distinct echoes, one after another, each a little quieter. SD-Reverb makes them with three short delay lines of 6 to 26 ms at the default Size, depending on Mode. The input is written into the lines, and what comes out of them after their delay is the stage’s output. That output is also scaled down to 40 per cent, mixed across the three lines, and written back in. Each echo therefore spawns a few fainter echoes before dying away after a few trips. The stage produces a handful of reflections rather than a tail, and the test at tests/test_early.cpp checks exactly that.
Call the three lines’ stored sequences and their delays samples. Per sample the stage reads the three delayed values and forms the stereo output from them.
It then scales by the feedback, , and mixes the three lines with a Householder reflection , where is a unit vector.
Last, it adds the input and writes the result back at the head of each line.
Those five steps together say that line 0 receives half the left input, line 1 half the right, and line 2 half the difference. The left output listens to lines 0 and 2, and the right output to lines 1 and 2 with the sign of line 2 flipped. Written as one recurrence,
The output is read before the input is written, so there is no zero-delay path. The first thing the early stage emits is the first delay samples after the input arrived. Each further trip round the loop multiplies by , which is about dB, so after four trips a reflection is down by more than 30 dB.
Each Mode has a table of three lengths in samples, written for 48 kHz (src/dsp/tables/DelaySets.hpp), and Size scales them.
| Mode | (samples) | at Size 0.5, 48 kHz |
|---|---|---|
| Hall | 385, 653, 1152 | 8.0, 13.6, 24.0 ms |
| Chamber | 289, 509, 768 | 6.0, 10.6, 16.0 ms |
| Cathedral | 480, 683, 1249 | 10.0, 14.2, 26.0 ms |
| Drift | 336, 541, 1055 | 7.0, 11.3, 22.0 ms |
Size 0 halves every length and Size 1 multiplies it by 1.5. The tables are in samples rather than milliseconds, and the section on sample rate says what that means at other rates.
The mixing vector is not fixed. It moves slowly around the balanced direction , at a third of the late field’s Morph Rate and with times its Morph Depth. The section on the trajectory gives the path. The early stage uses whatever vector it is handed at the start of each chunk.
Input diffusion
The source is src/dsp/AllpassDiffuser.hpp, with the blend in Reverb.cpp.
The early stage’s output is what feeds the late field. Before it gets there it passes through a chain of eight all-pass stages per channel. An all-pass filter changes when energy arrives without changing how much of each frequency there is. A single click going in comes out as a cluster of smaller clicks spread over some milliseconds. A steady tone comes out at exactly the same level. This smears the input in time, so the late field is fed something already dense and starts its wash sooner. The Diffusion knob blends between the plain early output at 0 and the fully smeared version at 1. The direct early output that goes to the wet mix is taken before this stage. The first reflections therefore stay crisp whatever Diffusion is set to.
One stage with delay samples and coefficient keeps a delay line .
Its transfer function and magnitude response are
the second because numerator and denominator on the unit circle are complex conjugates up to a factor of unit modulus. Its response to a single impulse is a first echo of at once, then after samples, then after . Each one is times the last. At that is and onward, whose squares sum to exactly 1.
The eight stages are in series, each channel with its own delays, rounded from milliseconds.
| stage | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|---|
| left (ms) | 1.7 | 2.3 | 3.1 | 4.1 | 5.3 | 6.7 | 8.1 | 9.3 |
| right (ms) | 1.9 | 2.7 | 3.5 | 4.5 | 5.7 | 7.1 | 8.5 | 9.7 |
The coefficient is for every stage, and the reverb never changes it. The chain’s transfer function is the product of the eight, still of unit magnitude. Its impulse response is the eight single responses convolved together, a cluster lasting some tens of milliseconds whose direct term is .
With the chain’s output and the effective diffusion, the late field receives
and likewise on the right. For strictly between 0 and 1 the combination is not itself all-pass. Its magnitude varies with frequency between and 1. Only the two ends of the knob are exactly neutral in level.
The effective diffusion is the knob raised by a floor. A long decay with little diffusion lets individual delay lines ring as audible pitches, so the reverb raises a floor under the knob as Decay grows.
Here is the smoothed Decay in seconds. There is no floor up to 4 s, and it rises to 0.5 at 8 s and beyond. The same also shapes the late field’s mixing vector.
The late field
The source is src/dsp/FDN.cpp, with src/dsp/MultibandDamping.cpp, src/dsp/FeedbackNonlinearity.hpp, src/dsp/RoomTone.hpp and src/dsp/HouseholderMatrix.hpp.
The late field is six delay lines in a loop, 15 to 180 ms long at the default Size depending on Mode. Over the whole Size range that is 7.5 to 270 ms. What comes out of each line is sent to the output, which is the reverb tail a listener hears. The same value is also made quieter, by a factor chosen so that the tail takes exactly Decay seconds to fall by 60 dB. The bass loses less per trip and the treble loses more. A gentle limiter that does nothing at normal levels comes next, then a barely audible whisper of filtered noise when Air Level is up. Last, a matrix that conserves the total energy mixes the value with the other five. The six results are written back into the lines together with the new input.
Six lines whose lengths share no common factor produce echoes that never line up, so the sum sounds smooth rather than fluttery. Every trip round the loop loses a fixed fraction, so the tail is an exponential decay. The fraction depends on frequency, so the tail’s colour decays too.
Call the lines’ stored sequences and their delays . Per sample, the field runs seven steps in this order. Step one reads the lines.
Step two takes the output taps, before anything else touches .
Step three applies the damping, per line, which is a frequency-dependent gain below 1.
Step four applies the soft limit, per line.
Step five adds the room tone, per line, and only when Air Level is above 0.
Step six mixes, with the Householder reflection and .
Step seven injects the input and writes the lines back.
When Entry Drift is off, is the fixed matrix
The left input enters lines 0, 2 and 4 with line 2 inverted, and the right enters lines 1, 3 and 5 with line 3 inverted, each at half amplitude. The output taps read lines 0, 1 and 2 for the left and 3, 4 and 5 for the right. Each output channel therefore hears two lines fed by one input channel and one fed by the other. That asymmetry is what makes the two outputs differ from each other while both channels share one network.
The delays
As for the early stage, each Mode has a table for 48 kHz scaled by Size.
| Mode | (samples) | at Size 0.5, 48 kHz (ms) |
|---|---|---|
| Hall | 961, 1259, 2893, 3103, 3367, 4560 | 20.0, 26.2, 60.3, 64.6, 70.1, 95.0 |
| Chamber | 727, 1141, 1173, 1861, 2101, 2161 | 15.1, 23.8, 24.4, 38.8, 43.8, 45.0 |
| Cathedral | 1921, 2588, 4553, 5609, 8047, 8641 | 40.0, 53.9, 94.9, 116.9, 167.6, 180.0 |
| Drift | 720, 1151, 2213, 2543, 2939, 3361 | 15.0, 24.0, 46.1, 53.0, 61.2, 70.0 |
Every table is pairwise coprime, meaning no two entries share a factor, which keeps the echo pattern from repeating early. The rounding in equation (29) does not preserve that property at other Size settings. The longest possible delay is samples, inside the 32768-sample buffers each line allocates.
Damping and the three bands
Sound in a room loses energy at every wall and in the air, and it loses more of its high frequencies than its low ones. SD-Reverb copies this by giving each line a gain below 1 that differs in three frequency regions, below about 250 Hz, between 250 and 4000 Hz, and above 4000 Hz. The middle region’s gain is set so that the tail falls 60 dB in exactly Decay seconds. The low region decays 1.3 times slower than that. The high region decays faster, by a margin that grows with Size. Air absorbs treble per metre travelled, so a bigger room loses more of it. Tone Tilt leans the whole curve darker or brighter.
A sample circulating in line makes one trip every samples, which is every seconds, and is multiplied by each trip. After seconds it has made trips and is at of its starting value. Requiring that to be , which is dB, gives the sixty-decibel gain .
Longer lines get a smaller so that all six decay at the same rate per second. At the Hall defaults, with s, the six values are .
The three bands get three decay times, , and .
Here is Tone Tilt. With the tilt at 0, the highs decay in at Size 0 and at Size 1. From the three times come three gains by equation (30), and the filter is built from their ratios.
The ratio is slightly above 1, holding the lows up relative to the mids, and is slightly below.
The filter is two shelves , each built from one one-pole low-pass, followed by the mid gain. With two states and per line,
Equation (33) adds times the low-passed signal. Below the corner the low-pass passes everything, so the gain there is , and above it the low-pass passes nothing, so the gain is 1. Equation (34) adds times the high-passed signal, which is the input minus the low-pass, so the gain is 1 below the corner and above. The corners are 250 Hz and 4000 Hz, with coefficients as in equation (3).
Both values are for . As a transfer function, with the one-pole low-pass,
At this is exactly , the low band’s target. At the highest representable frequency, , the one-pole low-pass is not quite zero but , which is 0.016 for the low corner and 0.256 for the high one. The high band’s gain is therefore approached rather than reached. For the longest Hall line the target is 0.686 and the value at is 0.706. The repository’s own test allows 5 per cent at the top of the band. The section on measurements shows what that does to a measured decay time.
Putting the three regions side by side at the Hall defaults, with Decay 2.4 s, Size 0.5 and tilt 0:
| band | multiplier | decay time |
|---|---|---|
| below 250 Hz | 1.3 | 3.12 s |
| 250 to 4000 Hz | 1 | 2.40 s |
| above 4000 Hz | 1.74 s |
The transitions are gradual, an octave or so wide on each side of the corner, because a one-pole filter is gentle.
The soft limit
If anything ever pushed the loop’s level far above normal, such as an extreme Decay with a very loud input, the values could grow without bound. A gentle curve applied to each line value makes that impossible. It is indistinguishable from a straight line for values below about 1, bends over for larger ones, and never lets anything through above 2. In normal use it does nothing measurable.
The threshold is
(src/dsp/FeedbackNonlinearity.hpp).
Its slope is , which is 1 at zero and falls to 0 at , where the curve meets the flat part with no kink. So never amplifies, since and . That is what lets the stability argument below count it as harmless. At it returns , at it returns , at it returns , and at exactly 2.
Room tone
A real room carries a floor of quiet air movement. When Air Level is above 0, each line has a tiny amount of soft noise added to it inside the loop. The noise therefore circulates through the loop and picks up the room’s colour, in the way any other content of the loop does. At the default Air Level of 0.30 it sits about 80 dB below full scale, far under the tail it rides on. Air Motion makes its level breathe slowly, over a cycle of about 6.7 seconds. At Air Level 0 the step is skipped entirely.
Each line
has its own noise generator
, from six different seeds, built in three parts (src/dsp/RoomTone.hpp). A 32-bit
xorshift
generator produces a pseudo-random integer
each sample, and the top 24 bits are mapped to a number
in
. This is white noise, with every frequency equally present.
Three one-pole low-passes with different corners are then summed with a direct term, which tilts the spectrum towards pink .
The three pole radii correspond to corners of about 18 Hz, 288 Hz and 4.3 kHz at 48 kHz. They scale with the sample rate. A one-pole low-pass at 8 kHz then takes the edge off, with and .
The amplitude is common to all six lines and wanders with a sine at 0.15 Hz.
So , which is dBFS, at the default. With Air Motion at 1 the level swings between and .
The noise is added in step five, after damping and before the mixing matrix. Its first appearance in the output is therefore only after it has been mixed and delayed. From then on it decays and re-enters like any other content of the loop.
The moving mixing matrix
The source is src/dsp/HouseholderMatrix.hpp and src/dsp/MatrixMorph.hpp.
The matrix
After damping, the six line values are mixed, so that each line’s next input is a combination of all six. The rule has to conserve energy, or the loop would either die faster than Decay says or run away. SD-Reverb uses a reflection , which mirrors the six-dimensional vector of line values in the plane perpendicular to a chosen direction . A mirror image is exactly as long as the original, which is the conservation needed.
With a unit vector ,
The second form is how it is computed. It is one dot product and one scaled subtraction, twelve multiplications for six lines, and the 36 matrix entries are never written down. Three properties follow, each pinned by a test in tests/test_matrix.cpp. The length
equals
for every
, so energy is conserved. Applying
twice gives
. The direction
is flipped, with
, and
whenever
is perpendicular to
.
Any unit vector gives a valid matrix. The balanced vector gives , where is the all-ones matrix. Every line then receives of its own value minus of each other line, which is the maximum mixing. A vector concentrated on one line, , gives and no mixing at all. Each line then feeds only itself and the network is six independent comb filters.
The Diffusion knob, through of equation (20), chooses a base vector between those two.
The value at is the balanced vector, and at it is the concentrated one. Halfway between, at , it reads .
The trajectory
A fixed matrix gives a fixed set of resonances. A tail that lasts seconds can then reveal them as a faint metallic ring. SD-Reverb moves the vector slowly around a closed loop near the base vector. Morph Rate sets the cycles per second and Morph Depth sets the amount. Every point on the loop is a unit vector, so the matrix is exactly energy-conserving at every instant. The movement therefore changes which resonances are excited without changing how fast the tail decays. The measurement below confirms this, with the decay time varying by under 3 per cent across the loop.
The loop is built from anchor directions, with for the late field and for the early stage and Entry Drift.
Here is the mean over , subtracted so that each anchor sums to zero. Each anchor is pushed away from the base vector by a per-mode spread and normalised.
A phase in advances once per chunk of samples, gated by the velocity scale of equation (10).
That happens only when both and Morph Depth is above 0. Otherwise stays where it is. The current vector is then
The operation is spherical linear interpolation , the way to move between two unit vectors along the sphere rather than along the chord. With ,
and at the code uses ordinary straight-line interpolation followed by normalisation. Every result has unit length, so is always exactly a reflection.
The phase therefore walks round the polygon on the sphere, and Morph Depth pulls the point back towards the base vector. Depth 0 means staying at and depth 1 means following the polygon. At Morph Depth exactly 0 the code returns without computing anything, which is why a rate change at depth 0 is bit-for-bit inaudible.
Two facts about the anchors follow from equation (44). Take , the last anchor, with . The argument of the sine is then plus a constant. The same holds for when and . The sine is then the same for every , and subtracting the mean leaves exactly zero. So one anchor of every loop is the base vector itself, and the loop passes through once per cycle. At the Hall spread of 0.35 with , the other five late anchors sit 26 to 31 degrees from the base vector.
| trajectory | base | rate | depth | spread | ||
|---|---|---|---|---|---|---|
| late field | 6 | 6 | Morph Rate | Morph Depth | by Mode | |
| early stage | 3 | 4 | balanced | Morph Rate / 3 | 0.7 times Morph Depth | by Mode |
| Entry Drift | 3 | 4 | balanced | Entry Rate Hz | 0.4 times Entry Drift | 0.35, mode 0 always |
Both the late and early trajectories are advanced with the same . The Entry Drift trajectory always advances with . The late field’s base vector is rebuilt every chunk from the smoothed , so turning Diffusion moves the whole loop.
Entry Drift
The source is src/dsp/InputMatrixMorph.hpp, with the crossfade in FDN.cpp.
With the injection matrix of equation (28) fixed, the same note played twice excites the room the same way twice. Entry Drift slowly changes which of the six lines the input enters, and by how much, so that the second note takes a slightly different door. It never changes how loud the input is, and it is kept from moving the stereo image. The three lines the left channel feeds are rotated among themselves, and the three the right channel feeds likewise, by the same 3 by 3 reflection.
A unit vector on a trajectory of its own defines . Given the input pair, the static injection is formed first, and the two triples are then reflected separately.
Because
is a reflection, each triple keeps its length. The left channel’s total injection is
before and after, whatever
is, and tests/test_entrydrift.cpp checks this to
.
One consequence of using a reflection is that is never the identity. Even at the smallest non-zero depth, where is the balanced vector , the left triple becomes . Switching the feature on therefore moves the entry pattern to a different fixed pattern of the same strength, and the trajectory then wanders around that one. The switch itself is covered by a 30 ms linear crossfade between the static and the reflected injection.
When Entry Drift returns to 0 the fade runs back to 0 and the static code path is taken again. With the feature at 0, Entry Rate has no effect on the output at all.
Stability of the loop
A feedback loop is safe when every trip round it loses energy. Three of the late field’s steps cannot add energy, and one always removes some. Together that is enough.
Look at what one pass of steps three to seven does to the vector of six line values. Ignore the input and the room tone for the moment. The reflection at step six leaves unchanged by equation (42). The soft limit at step four never increases any component, because . The damping at step three is a linear filter per line whose gain at every frequency is below 1. From equation (37) the low shelf’s magnitude lies between and about 1, and the high shelf’s between about 1 and . So whenever Decay is finite. The product is the low band’s own gain , below 1 for any line length and any finite Decay, the largest of 50 s included. The delay lines themselves only store values.
The energy in the loop is therefore multiplied by a factor below 1 every circulation, and with no input it decays geometrically to zero. The tests hold the tail below after 10 s of silence with a 2 s Decay, and finite for a full-scale impulse train at Decay 50 s. With input, the loop’s content is the sum of a decaying copy of every input sample that ever entered. That is exactly what a reverb tail is. The room tone is a bounded input like any other. This argument does not depend on being fixed, which is why the morphing above is free.
Iterating a matrix is the general statement of this. A map that is a length-keeping matrix times a gain below 1 has orbits whose length follows that gain.
Wet signal, width, mix and trim
The early stage’s direct output and the late field’s output are summed, the former scaled by Early Level.
If an engine crossfade is running, this sum is formed for both engines and blended. The stereo image is then adjusted in mid and side form.
Width 1 leaves the signal unchanged and 0 collapses it to mono, with both channels equal to . The default of 0.8 shrinks the difference between the channels to 80 per cent. This applies to the wet signal only.
Dry and wet are blended with an equal-power law .
Here is the smoothed Enabled value, which is 1 when on. Because , the total power stays constant across the knob when dry and wet are unrelated signals, which they are. At Mix 0.5 both gains are , and at the default 0.3 they are and .
The same holds for the right. Trim is clamped to dB, a factor of 15.85 either way, and is applied after the mix, so it scales the dry signal too.
When the Enabled switch is turned off, ramps towards 0 through the same per-chunk smoother as Mix. The wet signal fades out over about 22 chunks, which is 29 ms at 48 kHz. Once the wet path is not computed at all. The reverb’s memory is cleared and the output is until the switch returns. Switching back on therefore starts from an empty room.
The meters are peak detectors on the input and output chunks. They read the audio and change nothing.
Changing the delay lengths
The source is Reverb::maybeReconfigure and FDN::adoptFieldFrom.
Most controls can be applied to a running reverb by changing a number. The delay lengths need more, because shortening a delay line while a tail is in it would skip part of the stored sound and click. So the reverb keeps two complete early-plus-late engines, of which only one is normally running. When Mode or Size changes enough to need new lengths, the idle engine is set up with them. It is also given a copy of the sounding engine’s stored audio, so that it continues the same tail re-read at the new lengths. The output then crossfades from the old engine to the new one over 40 ms. If nothing is sounding at that moment the switch is made instantly instead.
Let be the delay scale. A reconfiguration is needed when the mode differs from the one applied, or when . Size therefore moves the delays in steps of at least 0.03, while its effect on damping through equation (31) is continuous.
The wet level is tracked by a peak follower over chunks, . If , or the reverb has not yet processed anything, the active engine is reconfigured and cleared in place. Otherwise the standby engine is configured and a crossfade begins. It receives the active engine’s delay-line contents and filter states, but neither its gains nor its delays. Per sample, with rising from 0 to 1 in steps of ,
Both engines process the same input during the fade, and both are handed the same mixing vectors. When reaches 1 the roles swap and the now-idle engine is cleared. A change requested during a fade is applied when the fade ends.
Parameter smoothing
A parameter change from the host is a step. Applied directly, a step in a gain is a click. So every continuous parameter glides towards its target, and the gliding is where the values the audio actually uses come from.
Each of the following moves 35 per cent of the way to its target once per chunk of 64 samples.
Those parameters are Mix, Width, Enabled, Early Level, Decay, Tone Tilt, Morph Depth, Entry Drift and Air Level. Three more are smoothed in a converted form: Trim as a linear gain, Diffusion as , and Low Cut as the coefficient . After chunks the remaining distance is . One per cent is reached after 11 chunks, which is 14.7 ms at 48 kHz. Decay, Tone Tilt, Morph Depth, Entry Drift and Air Level are pushed into the engines only when they have moved by more than . A static parameter therefore costs nothing.
Predelay is smoothed per sample instead, by equation (5).
Morph Rate, Entry Rate, Morph Adapt, Air Motion, Mode and Size’s effect on delay lengths are not smoothed, by design. The first three set speeds rather than positions, so a step in them produces no step in the audio. Size’s effect on damping is applied immediately, because it only changes the decay gains, and a change of gain alters a running tail gradually.
On the very first block after preparation every smoothed value is set to its target directly. A freshly loaded plugin therefore does not spend its first chunks ramping. This is also what makes Morph Depth 0 and Entry Drift 0 exact from the first sample.
Four parameters reach the reverb through a curve from a host value
in
(src/dsp/Parameters.cpp).
| parameter | real value | default |
|---|---|---|
| Decay | s, 0.2 to 50 s | 0.450045, giving 2.4 s |
| Predelay | ms | 0.316228, giving 20 ms |
| Low Cut | Hz, 20 to 500 Hz | 0.430677, giving 80 Hz |
| Morph Rate | 0 for , else Hz | 0.428690, giving 0.02 Hz |
Sample rate dependences
Every time constant expressed in seconds or hertz is converted with and therefore sounds the same at any rate. That covers the low cut and damping corners of equation (3), the pre-delay target, and the diffuser delays of equation (18). It covers the envelope follower of equation (9), the room tone’s 8 kHz roll-off and its 0.15 Hz wander. It covers the crossfade and Entry Drift fades, the morph phase increment of equation (46), and the decay gains of equation (30).
What is expressed in samples changes with the rate instead. The delay tables of equations (15) and (29) are sample counts for 48 kHz and are used as they are at any rate. At 96 kHz every early and late delay is half as long in time. The Hall’s 20 to 95 ms become 10 to 47.5 ms, the echo pattern compresses by two, and the room sounds smaller in that respect. The decay time is unaffected, because equation (30) compensates.
The per-chunk smoothing of equation (56) moves 35 per cent per 64 samples, so its 14.7 ms settling at 48 kHz is 7.3 ms at 96 kHz. The pre-delay glide of equation (5) has a time constant of 2000 samples, which is 41.7 ms at 48 kHz and 20.8 ms at 96 kHz.
The pink-noise coefficients of equation (39) are fixed pole radii, so the room tone’s spectrum also shifts up in proportion to the rate.
The plugin boundary
The three shells, for CLAP, AUv2 and AUv3, all present the same contract and hand the reverb the same two arrays.
Audio is stereo in, stereo out, 32-bit float and non-interleaved. The CLAP port is declared stereo and a block with fewer than two channels is refused. The AUv2 accepts only a two-channel float format. The AUv3 requires two output buffers and zeroes its input when none is connected or the source declares silence. A mono source has to be presented as stereo by the host.
Reported latency is zero. Pre-delay is a delay the user asked for, not one the host is told to compensate.
The reported tail is times Decay plus Predelay seconds, so an offline bounce keeps rendering after the last note.
Every process call runs with the processor’s flush-to-zero and denormals-are-zero flags set and restores them on exit (src/util/DenormalGuard.hpp). A decaying tail otherwise reaches numbers so small that the processor slows down handling them, and with the flags set such numbers become exactly 0. The test at tests/test_realtime_safety.cpp confirms a 20-second tail contains none.
The AUv2 answers the host’s bypass property with a 5 ms linear crossfade between its output and the untouched input, then clears the reverb. That path is outside the reverb and independent of the Enabled parameter. The CLAP and AUv3 shells have no host-bypass path of their own, and Enabled is the switch.
Nothing else in the shells touches audio. Parameter events are converted through the curves above and passed to the setters between slices of the block.
The algorithm in order
Per block of samples, in chunks of , the reverb does the following once per chunk.
- Glide the smoothed parameters by equation (56), and compute , , and .
- If Enabled has settled at off, clear the room once, output , and skip the rest.
- For each sample of the chunk, run the front end, equations (1) to (9), producing , and the last-sample of equation (10).
- Advance the late and early morph phases by equation (46) with . Compute and by equation (47) and hand them to the active engine, and to the standby one when fading.
- Run the early stage on , equations (11) to (13), giving .
- Run the diffusion chain on , equations (16) to (19), giving .
- Run the late field on , equations (21) to (27), giving . Entry Drift’s trajectory advances by equation (46) with at the start of the engine’s block.
- Repeat 5 to 7 for the standby engine if a crossfade is running.
- For each sample, apply equations (51) to (55), producing .
- Update the wet peak follower and raise the meters.
Inside the late field at step 7, each sample runs the same nine operations for six lines: read, tap, damp, limit, add tone, reflect, inject, write, advance.
Measurements against the equations
The repository’s offline tool renders a unit impulse through the reverb with Mix at 1 and Air Level at 0 and measures the result. Predictions below come from the equations above and measurements from the tool. The tool’s band measurements use one-pole probes at 150 Hz, at 500 to 2200 Hz and at 7 kHz. Those sit inside the shelf transitions rather than at the band extremes. That is why the low and high measurements land between the mid target and the band target.
The first sample a listener hears, at the Hall defaults with Predelay 20 ms, Early Level 0.35, Width 0.8 and Low Cut 80 Hz:
| quantity | predicted | measured |
|---|---|---|
| time of first wet sample | ms | 28.0 ms |
| its amplitude | 0.0900 | |
| same, Size 0 | ms | 24.0 ms |
| same, Size 1 | ms | 32.0 ms |
The amplitude is the early tap of line 0, which holds half the left input after the low cut’s first-sample gain . That is scaled by Early Level, then narrowed by Width through equation (52) with the right channel still silent. The late field’s first contribution arrives 961 samples later and is far smaller, because the diffuser’s direct term is .
Decay times follow. The mid band target equals Decay, the low band is times it, and the high band is times it at tilt 0.
| setting | band targets low, mid, high (s) | measured (s) |
|---|---|---|
| Hall, 2.4 s, Size 0.5 | 3.12, 2.40, 1.74 | 2.90, 2.27, 1.93 |
| Hall, 2.4 s, Size 0 | 3.12, 2.40, 2.16 | 2.95, 2.36, 2.24 |
| Hall, 2.4 s, Size 1 | 3.12, 2.40, 1.32 | 2.93, 2.20, 1.60 |
| Cathedral, 6 s, Size 0.5 | 7.80, 6.00, 4.35 | 7.36, 5.65, 4.80 |
| Hall, 2.4 s, Tilt +1 | 2.34, 2.40, 2.78 | 2.35, 2.53, 2.65 |
| Hall, 2.4 s, Tilt −1 | 3.90, 2.40, 0.70 | 3.48, 2.24, 1.09 |
The mid band lands within about 8 per cent of Decay in every case, a little short in all but the Tilt +1 row. That is inside the tolerance the repository’s own test sets. The ordering of low above mid above high holds in every untilted case. The high band at Size 1 measures 1.60 s against a 1.32 s target. The 7 kHz probe lies where the high shelf of equation (34) is only part-way to its full effect, exactly as equation (37) predicts.
Normalised echo density in 20 ms windows from 0 to 120 ms, where 1 means fully dense:
| Diffusion | density per window | time to reach 0.9 |
|---|---|---|
| 1.0 | 0.00, 0.01, 0.21, 0.72, 0.86, 0.91 | 100 ms |
| 0.0 | 0.00, 0.01, 0.02, 0.04, 0.05, 0.04 | beyond 120 ms |
Freezing the late trajectory at eight phases and measuring the broadband decay time gives 2.05 to 2.06 s at the Hall defaults, a spread of 0 per cent. At Tilt it gives 2.02 to 2.08 s, a spread of 3 per cent. The moving matrix does not move the decay, as the section on the trajectory claims.
Table of constants
| constant | value | where |
|---|---|---|
| chunk length | 64 samples | Reverb.cpp |
| parameter smoothing per chunk | 0.35 | Reverb.cpp |
| pre-delay glide per sample | 0.0005 | Reverb.cpp |
| pre-delay range | 0 to 200 ms | Reverb.cpp |
| low cut range | 20 to 500 Hz | Reverb.cpp |
| envelope attack and release | 50 ms and 1.2 s | AdaptiveMorphRate.hpp |
| envelope level scale | 4, full effect at 0.25 | AdaptiveMorphRate.hpp |
| early lines and feedback | 3 and 0.4 | EarlyReflections.hpp |
| early and late tap gain | EarlyReflections.cpp, FDN.cpp |
|
| injection gain | 0.5 | EarlyReflections.cpp, FDN.cpp |
| diffuser stages and coefficient | 8 per channel, 0.7 | AllpassDiffuser.hpp |
| diffusion floor | 0 below 4 s, 0.5 at 8 s | Reverb.cpp |
| late lines | 6 | FDN.hpp |
| damping corners | 250 Hz, 4000 Hz | FDN.cpp |
| low multiplier | , clamp 0.6 to 2.0 | FDN.cpp |
| high multiplier | , clamp 0.15 to 1.4 | FDN.cpp |
| soft-limit threshold | 3, ceiling 2 | FeedbackNonlinearity.hpp |
| room tone level | at Air Level 0.3 | RoomTone.hpp |
| room tone roll-off and oscillator | 8 kHz, 0.15 Hz, depth 0.9 | RoomTone.hpp |
| morph anchors | 6 late, 4 early, 4 entry | Reverb.hpp, InputMatrixMorph.hpp |
| loop spread by mode | 0.35, 0.50, 0.25, 0.80 | MatrixMorph.hpp |
| early morph rate and depth | , 0.7 times | Reverb.cpp |
| entry rate and depth | Hz, 0.4 times | Reverb.cpp, InputMatrixMorph.hpp |
| entry activation fade | 30 ms | FDN.cpp |
| engine crossfade | 40 ms, linear | Reverb.cpp |
| reconfigure threshold | scale change above 0.03 | Reverb.cpp |
| silent-switch threshold | wet peak below | Reverb.cpp |
| wet peak follower | 0.99 per chunk | Reverb.cpp |
| trim range | dB | Reverb.cpp |
| off threshold | Reverb.cpp |
|
| line buffers | 32768 late, 4096 early | FDN.cpp, EarlyReflections.cpp |
| reported tail | plus pre-delay | Plugin.cpp |
Source map
| stage | file |
|---|---|
| block loop, front end, mix, engines | src/dsp/Reverb.hpp, src/dsp/Reverb.cpp |
| late field loop | src/dsp/FDN.hpp, src/dsp/FDN.cpp |
| per-line damping | src/dsp/MultibandDamping.hpp, .cpp |
| soft limit | src/dsp/FeedbackNonlinearity.hpp |
| room tone | src/dsp/RoomTone.hpp |
| reflection matrix | src/dsp/HouseholderMatrix.hpp |
| matrix trajectory | src/dsp/MatrixMorph.hpp |
| Entry Drift | src/dsp/InputMatrixMorph.hpp |
| velocity gate | src/dsp/AdaptiveMorphRate.hpp |
| early stage | src/dsp/EarlyReflections.hpp, .cpp |
| diffusion | src/dsp/AllpassDiffuser.hpp |
| delay lines | src/dsp/DelayLine.hpp, src/dsp/InterpolatedDelayLine.hpp |
| delay tables | src/dsp/tables/DelaySets.hpp |
| parameter curves and defaults | src/dsp/Parameters.hpp, .cpp |
| denormal guard | src/util/DenormalGuard.hpp |
| shells | src/clap/Plugin.cpp, src/auv2/Component.cpp, src/auv3/AudioUnit.mm, src/auv3/RenderCore.hpp |
| measurements | tools/offline/main.cpp |
Figure sources
One script draws the figure. It plots the measured decay times of the table above against the ones equation (30) asks for. It also prints the spread it draws, so the caption and the plot cannot drift apart. The measurements are typed in from that table rather than recomputed, because they come from the compiled plugin. It needs NumPy and Matplotlib.
decay.py
uv run --with numpy --with matplotlib python3 decay.py
-
For a delay between 1 and 2 samples, . The newest neighbour is then read from the buffer slot that has not yet been written this sample. It therefore holds whatever was stored there one buffer length earlier. The pre-delay buffer holds at least samples, rounded up to a power of two. The stale value only enters through , and , scaled by , for delays under two samples, which is 0.04 ms. It is recorded for completeness and is not audible. ↩︎