Multirate FDN Reverb¶
pulp::signal::FdnReverb is Pulp's full-featured algorithmic reverb. It is a
true-stereo, wet-only, 16-line feedback delay network with a selectable internal
tank rate. Behind the same controls it also offers a classic tier —
parallel combs into series allpasses — for when a conventional reverb at a
fraction of the cost is the right trade. Use the smaller
signal::Reverb when you need a compact
mono-in/stereo-out effect with only decay, damping, and mix controls.
The public engine is header-only:
#include <pulp/signal/fdn_reverb.hpp>
pulp::signal::FdnReverb reverb;
// In Processor::prepare():
reverb.prepare(sample_rate, max_block_size);
reverb.set_mode(pulp::signal::fdn::Mode::hall);
// In Processor::process():
reverb.set_parameter(pulp::signal::fdn::Param::decay, 3.5);
reverb.process_block(in_left, in_right, wet_left, wet_right, num_samples);
process_block() produces wet signal only. Put a DryWetMixer or an equivalent
host/graph mix around it when you need a dry path. The engine reports zero
bufferable latency; interpolation_skew_samples() separately reports the
ratio-dependent causal interpolation bound.
Public API¶
FdnReverbT<SampleType> is the complete engine type;
FdnReverb aliases FdnReverbT<float>. Both tiers compute in single
precision (their block kernels are four-lane float), so FdnReverbT<double>
keeps a double buffer interface but converts to float at the block boundary
and returns float-precision results.
| Method | Contract |
|---|---|
prepare(double sample_rate, int max_block_size) |
Allocate worst-case storage, configure the host rate, load canonical defaults, select the default tank rate, and enter a cold state. Invalid rates fall back to 48 kHz; block capacity is at least one sample. |
set_mode(Mode mode) |
Stamp one mode's parameter defaults and host-rate output lowpass, then apply them immediately. Preserves the current predelay and width. |
set_parameter(Param param, double value) |
Set a target value. Clamps to the canonical range; rounds stepped parameters. Continuous values then follow the normal smoothing path. |
parameter(Param param) const |
Return the requested target value, not the current smoothed value. |
snap_parameters() |
Copy every target to its smoothed value and push the controls immediately. A changed tank rate is configured and returned to an exact cold phase. |
set_eq_band(int index, const fdn::EqBand& band) |
Author one of the ten in-loop EQ bands and redesign the bank at the current tank rate. Out-of-range indices are ignored. |
set_active_channels(int count) |
Set the active FDN line count, clamped to 2–16, while renormalizing input fan-out and output taps. |
set_flux_depth_db(double db) |
Set the maximum wandering in-loop absorption depth, clamped to the engine's 0–3 dB voicing range. |
set_flutter(bool enabled) |
Enable or disable fractional-delay motion in the in-loop diffusion stages. |
set_tier(fdn::Tier tier) / tier() const |
Select the network (default) or the classic tier. Takes effect at the next process_block(), which flushes the engine it switches to. See Tiers. |
reset() |
Apply pending targets, then clear every host-rate and tank-rate state according to the cold-state contract below. |
latency_samples() const |
Return 0; the engine introduces no block-buffer latency that a host can compensate. |
interpolation_skew_samples() const |
Return the current ratio-dependent bound from the causal four-point interpolation bridge. This is observability, not reported plugin latency. |
tank_rate() const |
Return the configured internal tank rate in hertz. |
duck_gain() const |
Return the transient ducker's most recently applied input gain in (0, 1]; after reset it is exactly 1. Intended for meters, diagnostics, and acceptance tests. |
tank() const |
Return read-only access to the underlying fdn::FdnTank<float>. Its telemetry supports structural validation; application code should prefer the façade's controls. |
process_block(const SampleType* in_left, const SampleType* in_right, SampleType* out_left, SampleType* out_right, int sample_count) |
Process true-stereo input into wet-only stereo output. Non-positive counts are no-ops; oversized calls are internally split into prepared-size chunks. |
Call prepare() before any other operation. Input and output pointers must
address at least sample_count samples. In-place processing is not part of the
documented contract; use distinct input and output buffers.
Controls and modes¶
All modes run the same DSP. A mode stamps parameter defaults and selects a host-rate output lowpass; it does not select a separate processing path.
| Mode | Character |
|---|---|
room |
Short, dense, relatively bright room |
hall |
General-purpose hall |
galaxy |
Long, modulated tail with Bloom and some shimmer |
shimmer |
Pitch-shifted, expansive tail |
lofi |
Low-rate, dark, driven texture |
set_mode() leaves predelay and width at their current values because they
are performance choices rather than part of the authored voicing.
| Parameter | Range | Meaning |
|---|---|---|
decay |
0.1–60 s | Requested broadband decay time |
size |
0–1 | Scales the delay-line lengths |
predelay |
0–250 ms | Delay before tank excitation |
damp_hi |
0–1 | Frequency-dependent high-frequency decay |
damp_lo |
0–1 | Frequency-dependent low-frequency decay |
diffusion |
0–1 | Input and in-loop scattering |
mod |
0–1 | Delay motion depth |
shimmer |
0–1 | Energy-normalized pitch-shifted loop injection |
drive |
0–1 | In-loop saturation; perceived-level makeup stays outside the loop |
bloom |
0–1 | Lifts long-decay feedback toward, but never to, unity |
width |
0–1 | Wet output stereo width |
tank_rate |
integer 0–7 | Internal rate index; see below |
The tank-rate values are indices, not rates in hertz:
| Index | Tank rate |
|---|---|
| 0 | 16 kHz |
| 1 | 20 kHz |
| 2 | 24 kHz |
| 3 | 32 kHz |
| 4 | 44.1 kHz |
| 5 | 48 kHz |
| 6 | 64 kHz |
| 7 | 96 kHz |
Lower rates are deliberately darker and grainier. tank_rate is a texture and
setup control, not a generic quality selector.
Continuous parameters smooth over 5 ms at the engine's 32-sample control
cadence. snap_parameters() applies all pending targets immediately, which is
appropriate after preset loading or before a deterministic offline render.
Authored in-loop EQ, flux depth, and diffusion flutter use set_eq_band(),
set_flux_depth_db(), and set_flutter() rather than host-automatable
parameters because changing in-loop gain can change the realized decay.
Tiers¶
fdn::Tier::network is the engine this guide describes. fdn::Tier::classic
runs ClassicReverbT (<pulp/signal/classic_reverb.hpp>, also usable on its
own): eight lowpass-feedback combs per rail in parallel, four allpasses per rail
in series, all at the host rate, no resampling. It is the conventional
Schroeder/Moorer reverb, and it costs a small fraction of the network.
The tiers share the parameter set, the modes, wet-only output, and zero latency. Each parameter means the same thing in both where both have the machinery:
| Parameter | Classic tier |
|---|---|
decay |
T60. Every comb's gain follows the absorptive law 10^(-3 D / (T60 fs)). |
size |
Scales comb and allpass lengths. Lengths change when parameters are snapped (snap_parameters(), reset(), set_mode()), not mid-stream: integer comb lengths cannot glide. |
predelay |
Milliseconds before the combs. |
damp_hi |
In-loop one-pole lowpass on the network's cutoff curve, its loss scaled per comb by length. |
diffusion |
Allpass coefficient, diffusion x 0.7. |
width |
Mid/side width of the wet output. |
damp_lo, mod, shimmer, drive, bloom, tank_rate |
Stored, no effect. |
Expect the classic tier's density to be a classic reverb's: its echo-density curve reaches the Gaussian plateau later than the network's (within 250 ms at 1.3 s decay, against about 60 ms for the network at its defaults), and without diffusion it reaches it at least 50 ms later or not at all, because nothing in a comb smears each recirculation.
Under automation the classic tier smooths each continuous control through
three one-pole stages at the 32-sample control rate, then moves the comb
gains, damping and width to each tick's values in per-sample steps. A sweep of
decay or damp_hi with targets set once per 128-sample host block leaves no
measurable energy at the control rate or the block rate around a probe tone;
the allpass coefficient and predelay still change at the tick.
A tier change is a clean flush, like a tank-rate change.
In the Forge catalog the five reverb.* nodes carry the tier as a thirteenth
baked parameter, tier (node-local id 13, a stepped fdn / classic
choice, default fdn), appended after the engine's twelve so every existing
patch keeps rendering the network bit for bit.
Lifecycle and real-time contract¶
prepare(host_rate, max_block_size) allocates every delay and scratch buffer
for the worst supported tank rate, and the classic tier's buffers. After
prepare(), parameter setters, snap_parameters(), reset(),
process_block(), live tank-rate changes, and tier changes do not allocate.
reset() has a strong cold-state contract:
- It first applies every pending parameter target.
- It clears the multirate bridge, predelay, pre-diffusion, tank, ensemble, ducker, input and output filters, telemetry, and control cadence.
- It snaps delay lengths to their targets and atomically re-derives length-dependent decay, damping, and stability state.
- The next render therefore matches a freshly prepared engine with the same settings and deterministic modulation phase.
A tank-rate change is intentionally a hard flush, not a crossfade. The change
is applied at a processing chunk boundary before either resampler leg runs, and
the current tail is discarded. Do not automate tank_rate as a performance
macro. If a preset load changes the rate, call snap_parameters() so the new
rate and all other pending controls become one cold state before rendering.
Architecture and ownership¶
The public façade coordinates the host-rate and tank-rate domains. Internal
headers under pulp/signal/fdn/ own one kind of DSP state each:
| Module | Responsibility |
|---|---|
fdn_reverb.hpp |
Public lifecycle, smoothing, wet-output chain, and host/tank coordination |
fdn/config.hpp |
Structural constants, tuning data, parameter ranges, and mode tables |
fdn/multirate.hpp |
Host-to-tank and tank-to-host conversion plus interpolation phase |
fdn/tank.hpp |
Delay network, feedback ordering, control-derived state, and stability normalization |
fdn/diffusion.hpp |
Input and in-loop allpass cascades |
fdn/modulation.hpp |
Deterministic sine and mean-reverting delay motion |
fdn/loop_eq.hpp |
Authored EQ and absorptive flux filters |
fdn/shimmer.hpp |
Granular pitch shifting and energy-normalized injection |
fdn/stages.hpp |
Transient ducking, output ensemble, and limiter helpers |
fdn/filter_state.hpp |
Shared coefficient-driven recursive biquad state for the scalar component APIs |
fdn/sanitize.hpp |
Topology-neutral finite-value recovery |
classic_reverb.hpp |
The classic tier |
detail/simd_lanes.hpp |
Four-lane float / two-lane double vectors for the block kernels |
Block processing¶
The engine runs the tank a control period (32 tank samples) at a time, because controls are constant inside a period and every loop in the tank is far longer than one:
- Each line reads its whole span along time — a contiguous Hermite read when
the span shares one integer delay — then runs its four in-loop allpasses as
one fused pass along time. Every read lands on a sample written before the
span began;
FdnTank::process_block()splits a span wherever that would not hold. - Damping, flux, the decay gains, the 16-point Hadamard, and the input fan-out run one sample at a time across all sixteen lines as four 4-lane vectors, with their recursive state held in registers for the span.
- The writes are committed after the span.
The bridge carries both rails through each resampling filter as one two-lane
double vector, with the recursion rearranged so it is one multiply-add deep.
Delay modulation and the ensemble's LFOs are rotation oscillators re-anchored
from an exact double phase every 4096 samples instead of a sin() per line per
sample.
Precision follows the cost of being wrong: delay positions, modulation, the
bridge filters, and the flux biquads stay double precision; the delay lines,
diffusion, damping, and the matrix are single precision, as the ring buffers
always were. The block engine is not bit-identical to the per-sample
formulation it replaced, but reproduces its impulse-response envelope (40 ms RMS
windows) within 0.01 dB for every mode, with drive and with flutter, and on
the three reference voicings its T60 within 0.5% and mixing time within one
analysis hop — see
test_fdn_reverb_block_engine.cpp, whose references
tools/audio/fdn_reference/generate.sh records from v0.1004.0.
Every per-sample expression is evaluated the same way whatever span it falls in, and recursive state is snapped and sanitized on fixed sample grids counted from reset, never at caller block boundaries. A render is therefore bit-identical however the host divides it, down to one-sample blocks.
Rings that advance in lockstep (the sixteen lines, the in-loop allpass stages) are staggered inside their allocations: equal-sized rings written at equal offsets otherwise land on the same cache sets and low address bits, and loads stall behind unrelated stores.
Keep these boundaries when extending the engine:
- The façade owns cross-stage lifecycle order; a stage owns its own state and reset implementation.
- The tank owns the meaning of smoothed tank controls. The façade owns target values, smoothing, and host/tank rate transitions.
- Modes and parameter ranges are data in
config.hpp, not branches copied into the engine or catalog wrapper. - Anything that can add feedback-loop energy must participate in the tank's worst-case gain normalization. Loudness compensation belongs after the feedback tap.
- Host/catalog identifiers and parameter injection belong in
pulp/host/forge_fdn_reverb_catalog.hpp; the signal engine must not learn about graph node IDs orBakedParamView. - Generic signal utilities must not depend on the FDN. An internal helper
should move into a general
pulp::signalmodule only when another processor has a real use for the same contract.
Finite-state recovery¶
The FDN uses defense in depth because one corrupt recursive line can spread through the Hadamard matrix to all sixteen lines. Recovery stays local, but the block kernels place the guards at the boundaries rather than on every multiply-add:
- Input is sanitized where it enters each bridge leg; the scalar component
APIs (
AllpassStage::process,FluxBank::process,LoopEq::process,ButterworthState,TransientDucker::process) keep their per-samplefdn::SanitizedBiquadState/fdn::finite_or_zero()guards. - The damping stage clamps its input to the sanity ceiling, so the damping, flux, and allpass recursions see finite, bounded input; each recursion is then bounded by construction.
- Every recursive state (damping, flux, ducker envelopes, bridge and wet filters) is snapped to zero and zeroed if non-finite every 32 samples of its own clock — often enough that no state at the snap threshold can reach the denormal range, and on a grid that never depends on host blocks.
- The post-Hadamard boundary clamps to its tighter ceiling before fan-out; the delay write boundary kills non-finite values, applies the wider emergency ceiling, and snaps.
- Equal-rate and resampled bridge paths follow the same non-finite recovery policy.
Do not replace these local guards with output-only sanitization. By the time a bad value reaches the stereo sum, recursive state may already be contaminated.
Validation ownership¶
Changes to this engine have two focused owners:
pulp-test-fdn-reverbproves DSP structure and behavior, including stability, decay, bandwidth, reset/rate-switch parity, finite recovery, determinism, and drive distortion.test_fdn_reverb_block_engine.cpppins the block engine to the per-sample formulation's output envelope, T60, and mixing time, each with a negative control, and proves block-partition exactness for every mode;test_classic_reverb.cppcovers the classic tier.pulp-test-fdn-reverb-catalogproves the production host wrapper, parameter propagation, lifecycle behavior, allocation-free reset/rate switching, and the tier parameter: its default renders the network exactly, andclassicrenders the classic engine.
The registrations live together in test/cmake/fdn_reverb_tests.cmake. Run the
focused Release targets after changes:
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
tools/ci/governed-build.sh cmake --build build --target pulp-test-fdn-reverb \
pulp-test-fdn-reverb-catalog
./build/test/pulp-test-fdn-reverb
./build/test/pulp-test-fdn-reverb-catalog
Cost is measured, not gated: pulp-dsp-throughput-benchmark --filter reverb
(configure with -DPULP_BENCHMARK=ON) reports the reverb.fdn and
reverb.classic rows at 32/128/512-sample blocks.
Behavioral claims should be tested at the narrowest observable layer. For example, prove public drive propagation from tank telemetry and measure saturator distortion at the tank output, where downstream makeup and limiting cannot create a false positive. Pair reset/rate-switch comparisons with an explicit all-finite check so a non-finite difference cannot masquerade as parity.