5.3 KiB
engines
(no submodules)
Purpose
The reverb.engines module owns the per-sample DSP kernels that ReverbPlugin calls every block: the Schroeder-Moorer algorithmic engine (comb-bank + all-pass diffusion), the direct-convolution engine (used when an IR is loaded), the pre-delay tap, and the per-channel state shape they all consume. Splitting these out of the plugin file keeps reverb.ts focused on the plugin lifecycle (parameters, setup, presets, loadImpulseResponse) and concentrates the inner-loop hot paths in one allocation-free helper that other reverb-shaped plugins (a future plate-only reverb, a Hadamard-matrix FDN) can reuse without dragging the full ReverbPlugin in.
The engines are pure functions over a mutable ChannelState: every per-sample call updates the state's circular buffer and indices in place, so the plugin's processBlock allocates nothing.
Features
ChannelState,CombState,AllPassStateinterfaces — the full per-channel state shape, sized once atsetup().COMB_DELAYS_MS,ALLPASS_DELAYS_MS— fixed Freeverb-style delay tables, scaled per session sample rate.MAX_FEEDBACK,ALLPASS_FEEDBACK— clamping and diffusion constants used by both engines.createChannelState(sampleRate, preDelayMaxMs, irLength)— fresh per-channel state allocator.readPreDelay(state, sample, delaySamples)— pre-delay tap; returnssampledirectly whendelaySamples === 0.algorithmicStep(state, input)— Schroeder-Moorer per-sample step.convolveStep(state, ir, input)— direct time-domain convolution per-sample step.updateCombFeedbacks(state, sampleRate, decaySec)— recompute comb feedbacks from the current decay time (called once per block by the plugin).resetChannelState(state)— clear every buffer + index in place; no re-allocation.
Usage: Basic Example
import {
createChannelState,
algorithmicStep,
updateCombFeedbacks,
} from "@suaveplan/audio-fx-plugin-reverb";
const state = createChannelState(48_000, 200, 0);
updateCombFeedbacks(state, 48_000, 2.5);
for (let i = 0; i < input.length; i++) {
output[i] = algorithmicStep(state, input[i]);
}
Usage: Advanced Example
import {
createChannelState,
algorithmicStep,
convolveStep,
readPreDelay,
updateCombFeedbacks,
resetChannelState,
} from "@suaveplan/audio-fx-plugin-reverb";
// Build a custom dual-path reverb: pre-delay → convolver (if loaded)
// or algorithmic fallback, mixed wet/dry.
const states = Array.from({ length: channels }, () =>
createChannelState(sampleRate, 200, impulseResponse?.length ?? 0)
);
for (let ch = 0; ch < channels; ch++) {
const state = states[ch]!;
updateCombFeedbacks(state, sampleRate, decaySec);
for (let i = 0; i < blockSize; i++) {
const sample = input[ch]![i]!;
const wetIn = readPreDelay(state, sample, preDelaySamples);
const wetOut = impulseResponse
? convolveStep(state, impulseResponse, wetIn)
: algorithmicStep(state, wetIn);
output[ch]![i] = dry * sample + wet * wetOut;
}
}
// Transport stop: clear all state without reallocating.
for (const state of states) resetChannelState(state);
API Reference
CombState,AllPassState,ChannelState— state interfaces consumed by every engine function.COMB_DELAYS_MS: readonly [29.7, 37.1, 41.1, 43.7],ALLPASS_DELAYS_MS: readonly [5, 1.7]— delay tables.MAX_FEEDBACK = 0.997,ALLPASS_FEEDBACK = 0.5.createChannelState(sampleRate, preDelayMaxMs, irLength): ChannelState.readPreDelay(state, sample, delaySamples): number.algorithmicStep(state, input): number.convolveStep(state, ir, input): number.updateCombFeedbacks(state, sampleRate, decaySec): void.resetChannelState(state): void.
Implementation Notes
The algorithmic step parallels Freeverb's topology: four parallel comb filters with sample-rate-scaled delays, their outputs averaged, then fed through two series all-pass diffusers. The comb feedback formula exp(-3 · ln10 · τ / T) (where τ is the delay in seconds and T is the RT60) is mathematically equivalent to 10^(-3·τ/T) and gives an analytically-correct RT60 in the linear-decay limit; the explicit MAX_FEEDBACK = 0.997 clamp guarantees the engine cannot diverge regardless of the configured decay value.
convolveStep is direct time-domain convolution; it costs O(blockSize · irLength) per channel, which is appropriate for short room and plate IRs (up to a few thousand samples). Long cathedral IRs benefit from a partitioned FFT convolver — that is deliberately deferred until a real bottleneck demonstrates the need, since the algorithmic path covers long tails efficiently and the convolution path stays simple and obviously correct.
readPreDelay short-circuits when delaySamples === 0 so the bit-transparent dry path stays tight at zero pre-delay. The buffer is sized to the maximum allowed pre-delay (200 ms × sample rate) at setup and never reallocated; the current pre-delay length is read per-block from the live parameter so automation lanes stay continuous.
resetChannelState mutates buffers in place (fill(0)) rather than reallocating so the plugin's reset() honours the spec's "buffers are the same instances as before reset" guarantee.