Bank of two-pole modal resonators, stored structure-of-arrays in lane groups so the per-sample state update auto-vectorizes across modes.
More...
template<typename SampleType = float>
class pulp::signal::ModalBankT< SampleType >
Bank of two-pole modal resonators, stored structure-of-arrays in lane groups so the per-sample state update auto-vectorizes across modes.
Each mode is a coupled-form (rotating-phasor) resonator: the state pair (u, v) is multiplied every sample by the scaled rotation [rc -rs; rs rc], rc = r*cos(w), rs = r*sin(w), with pole radius r = 10^(-3 / (t60 * fs)) and pole angle w = 2*pi*f/fs. Input is injected as gain*(cos(w), sin(w)) and v is read out, which makes the mode's impulse response exactly gain * r^n * sin((n+1) w) — so through a unit-gain pickup (the default), ModalMode::gain reads directly as the mode's amplitude in the rendered impulse response, and t60_s as its measured decay.
The injection pair is proportional to the rotation pair — gain*cos(w) = (gain/r)*rc — so one coefficient could replace the two. Do not: folding them costs throughput rather than saving it. Injecting into u ahead of the rotation puts an add on the u -> u' dependency chain, and this loop is latency-bound on that chain, not throughput-bound on its arithmetic. Kept separate, gain*cos(w)*x - rs*v evaluates off the chain and u -> u' folds into a single FMA (measured: folding costs 30% at one pickup and 3.5x at four).
The coupled form is chosen over the cheaper direct form (y = b0 x + a1 y[n-1] + a2 y[n-2]) for two measured reasons, both about the state's physical meaning. Direct-form state is a pair of past output samples, so the amplitude it implies depends on the coefficients that produced it: retuning under a held state silently rescales the ringing mode by up to w_old/w_new, phase-dependently (a 220 -> 440 Hz retune measures anywhere from 0.50x to 1.00x depending on where in the cycle it lands, and a three-octave glide loses 63% of the mode). Coupled-form state is the phasor itself; the rotation's singular values are both r regardless of w, so a retune preserves amplitude exactly (measured 0.999x, glide within 0.9% of the ideal decay) and no state fixup is needed. Second, the direct form encodes frequency in a1 = 2 r cos(w), which crowds against 2.0 as w -> 0 and loses it to float rounding: a 20 Hz / 8 s mode lands 7.5 cents flat, a 30 Hz / 5 s mode 2.5 cents flat. The coupled form carries frequency in rs = r sin(w), which keeps full relative precision at low w — the same modes land within 0.001 cents. Both forms resolve t60 to better than 0.15% in float.
The process loop is mode-major: each group of kLanes modes keeps its coefficients and state in local fixed-size arrays across the whole block, so per-block memory traffic is one pass over the state arrays plus the L1-resident input/output buffers, and the lane loops are elementwise (no cross-lane dependence) which vectorizes without -ffast-math.
Decayed state is snapped to zero at block boundaries, so a silent bank settles to exact zeros and cannot park denormals in the recursion across blocks. Within a block on x86, hold a ScopedFlushDenormals in the caller (as with the other recursive processors) if denormal stalls matter.
RT contract: prepare() allocates; set_modes(), set_pickup_gains(), process_add(), and reset() allocate no memory after prepare(). set_modes() evaluates transcendentals per mode — cheap enough for control-rate retuning of a few hundred modes, but retune large banks from a non-RT thread.