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. 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>.
| 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. |
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<SampleType>. 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.
Lifecycle and real-time contract¶
prepare(host_rate, max_block_size) allocates every delay and scratch buffer
for the worst supported tank rate. After prepare(), parameter setters,
snap_parameters(), reset(), process_block(), and live tank-rate 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 |
fdn/sanitize.hpp |
Topology-neutral finite-value recovery |
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 is local:
- Recursive filters use
fdn::SanitizedBiquadState, the canonical coefficient-driven Direct Form II Transposed state for this engine. fdn::finite_or_zero()is topology-neutral and is shared by delay, diffusion, envelope, filter, and resampling code without introducing a biquad dependency.- Recursive state snaps denormals to zero as the tail decays.
- The post-Hadamard boundary kills non-finite values and applies its tighter finite clamp before fan-out; the delay write boundary retains a wider emergency ceiling.
- 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.pulp-test-fdn-reverb-catalogproves the production host wrapper, parameter propagation, lifecycle behavior, and allocation-free reset/rate switching.
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
cmake --build build --target pulp-test-fdn-reverb \
pulp-test-fdn-reverb-catalog -j"$(sysctl -n hw.ncpu)"
./build/test/pulp-test-fdn-reverb
./build/test/pulp-test-fdn-reverb-catalog
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.