Pulp 1.88.0
Cross-platform audio plugin framework
Loading...
Searching...
No Matches
pulp::signal::ModalBankT< SampleType > Class Template Reference

Bank of two-pole modal resonators, stored structure-of-arrays in lane groups so the per-sample state update auto-vectorizes across modes. More...

#include <modal_bank.hpp>

Public Member Functions

void prepare (double sample_rate, int max_modes, int max_pickups=1)
 Allocate storage for up to max_modes modes and max_pickups listening positions at sample_rate.
void set_modes (std::span< const ModalMode > modes)
 Load a mode set (up to the prepared max; excess modes are ignored).
void set_pickup_gains (int channel, std::span< const SampleType > gains)
 Set how loudly pickup channel hears each mode.
void process_add (const SampleType *input, SampleType *output, int num_samples, int channel=0)
 Excite and run the bank into one output: adds pickup channel's view of the summed mode outputs for num_samples of input into output (accumulating, so several banks can layer into one voice buffer).
void process_add (const SampleType *input, SampleType *const *outputs, int num_channels, int num_samples)
 Excite and run the bank into num_channels outputs, one per pickup: pickup c accumulates into outputs[c].
void reset ()
 Zero all resonator state (call on discontinuities / re-strike from silence).
int mode_count () const
int max_modes () const
int max_pickups () const
double sample_rate () const

Static Public Attributes

static constexpr int kLanes = 16
 Lane-group width.
static constexpr int kMaxPickups = 8
 Upper bound on simultaneous pickups.

Detailed Description

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.

Member Function Documentation

◆ max_modes()

template<typename SampleType = float>
int pulp::signal::ModalBankT< SampleType >::max_modes ( ) const
inline

◆ max_pickups()

template<typename SampleType = float>
int pulp::signal::ModalBankT< SampleType >::max_pickups ( ) const
inline

◆ mode_count()

template<typename SampleType = float>
int pulp::signal::ModalBankT< SampleType >::mode_count ( ) const
inline

◆ prepare()

template<typename SampleType = float>
void pulp::signal::ModalBankT< SampleType >::prepare ( double sample_rate,
int max_modes,
int max_pickups = 1 )
inline

Allocate storage for up to max_modes modes and max_pickups listening positions at sample_rate.

Every pickup starts at unit gain on every mode, so a freshly prepared bank reads out its modes' ModalMode::gain directly.

◆ process_add() [1/2]

template<typename SampleType = float>
void pulp::signal::ModalBankT< SampleType >::process_add ( const SampleType * input,
SampleType *const * outputs,
int num_channels,
int num_samples )
inline

Excite and run the bank into num_channels outputs, one per pickup: pickup c accumulates into outputs[c].

One pass over the resonator state serves every pickup, which is the point — the body is simulated once and listened to from several places. Channels past the prepared max_pickups are ignored and their outputs left untouched.

◆ process_add() [2/2]

template<typename SampleType = float>
void pulp::signal::ModalBankT< SampleType >::process_add ( const SampleType * input,
SampleType * output,
int num_samples,
int channel = 0 )
inline

Excite and run the bank into one output: adds pickup channel's view of the summed mode outputs for num_samples of input into output (accumulating, so several banks can layer into one voice buffer).

Pass a zero input to ring out.

◆ reset()

template<typename SampleType = float>
void pulp::signal::ModalBankT< SampleType >::reset ( )
inline

Zero all resonator state (call on discontinuities / re-strike from silence).

Pickup gains and modes are unaffected.

◆ sample_rate()

template<typename SampleType = float>
double pulp::signal::ModalBankT< SampleType >::sample_rate ( ) const
inline

◆ set_modes()

template<typename SampleType = float>
void pulp::signal::ModalBankT< SampleType >::set_modes ( std::span< const ModalMode > modes)
inline

Load a mode set (up to the prepared max; excess modes are ignored).

This is also the retune path: existing resonator state is preserved lane-for-lane, and because that state is the mode's phasor rather than a pair of past outputs, retuning a ringing bank at block rate changes its pitch without stepping its amplitude and without a state fixup. Call reset() for a clean start instead.

◆ set_pickup_gains()

template<typename SampleType = float>
void pulp::signal::ModalBankT< SampleType >::set_pickup_gains ( int channel,
std::span< const SampleType > gains )
inline

Set how loudly pickup channel hears each mode.

gains is indexed by mode; modes past its end keep their previous gain. Weights from fill_mode_weights() put the pickup at a position on the body.

Member Data Documentation

◆ kLanes

template<typename SampleType = float>
int pulp::signal::ModalBankT< SampleType >::kLanes = 16
staticconstexpr

Lane-group width.

16 floats = one cache line; the per-group local arrays fit the vector register file on NEON and AVX targets.

◆ kMaxPickups

template<typename SampleType = float>
int pulp::signal::ModalBankT< SampleType >::kMaxPickups = 8
staticconstexpr

Upper bound on simultaneous pickups.

Each pickup adds a multiply and a reduction per mode-sample, and its lane block is held in registers across the block, so this is deliberately small: pickups model listening positions on one body, not a mixer.


The documentation for this class was generated from the following file:
  • /home/runner/work/pulp/pulp/core/signal/include/pulp/signal/modal_bank.hpp