Signal Processing¶
The pulp::signal namespace provides 30+ DSP processors for use inside Processor::process(). Process methods are real-time safe after construction/configuration/prepare() as documented per helper; setup methods that allocate tables, delay buffers, coefficient storage, or impulse-response data must run off the audio thread. Include individual headers or use the convenience header:
All processors live in core/signal/include/pulp/signal/.
1. Utilities¶
SmoothedValue¶
Linearly interpolates toward a target value over a configurable ramp time. Use for parameter smoothing to avoid zipper noise.
SmoothedValue<float> gain_smooth;
// In prepare():
gain_smooth.set_ramp_time(0.02f, sample_rate); // 20ms ramp
// In process():
gain_smooth.set_target(new_gain); // begins ramping
for (int i = 0; i < num_samples; ++i)
output[i] = input[i] * gain_smooth.next();
| Method | Description |
|---|---|
set_ramp_time(seconds, sample_rate) |
Set smoothing duration. Minimum 1 sample. |
set_target(value) |
Begin ramping toward value. |
set_immediate(value) |
Jump to value instantly (no ramp). |
next() |
Return next interpolated sample and advance. |
skip(n) |
Advance by n samples without returning values. |
is_smoothing() |
True if ramp is still in progress. |
current() |
Current value without advancing. |
target() |
Target value. |
Template parameter: T (default float). Also works with double.
Sample rate dependency: set_ramp_time() must be called with the current sample rate.
Caveat: Fixed-state and allocation-free. Configure ramp time outside the
inner sample loop; set_target(), next(), skip(), and accessors are
real-time safe.
Gain¶
Applies gain in linear or dB scale. Includes db_to_linear() and linear_to_db() free functions.
signal::Gain gain;
gain.set_gain_db(-6.0f);
// Per-sample:
float out = gain.process(input_sample);
// Per-buffer:
gain.process(buffer, num_samples); // in-place
| Method | Description |
|---|---|
set_gain_db(float db) |
Set gain in decibels. |
set_gain_linear(float linear) |
Set gain as linear multiplier. |
gain_db() |
Current gain in dB. |
gain_linear() |
Current gain as linear multiplier. |
process(float) |
Process one sample, returns result. |
process(float*, int) |
Process buffer in-place. |
Free functions: db_to_linear(float db) and linear_to_db(float linear).
Sample rate dependency: None.
DryWetMixer¶
Crossfades between dry and wet signals. Mix 0.0 = fully dry, 1.0 = fully wet.
signal::DryWetMixer mixer;
mixer.set_mix(0.5f); // 50/50
mixer.prepare(max_channels, max_block_size);
// Before running the wet processor:
const float* dry_channels[] = {dry_left, dry_right};
mixer.push_dry(dry_channels, 2, num_samples);
// After the wet processor has written its output in-place:
float* wet_channels[] = {wet_left, wet_right};
mixer.mix_wet(wet_channels, 2, num_samples);
| Method | Description |
|---|---|
set_mix(float) |
Set mix ratio. Clamped to [0, 1]. |
mix() |
Current mix value. |
set_curve(MixCurve) |
Select the crossfade law. |
set_wet_latency(int samples) |
Delay the dry path to compensate wet-path latency. |
prepare(int max_channels, int max_block_size) |
Allocate internal dry/latency storage. |
push_dry(float* const* dry, int channels, int samples) |
Capture dry input before wet processing. |
mix_wet(float* const* wet, int channels, int samples) |
Mix captured dry into the wet buffer in-place. |
reset() |
Clear delay/dry history. |
Default mix: 1.0 (fully wet).
Sample rate dependency: None.
Caveat: prepare() and set_wet_latency() allocate or resize internal
storage. Call them outside the audio callback. After prepare(), push_dry(),
mix_wet(), set_mix(), set_curve(), and reset() are real-time safe for
channel/block sizes within the prepared capacity.
Panner¶
Equal-power stereo panner. Pan -1.0 = full left, 0.0 = center, +1.0 = full right.
signal::Panner panner;
panner.set_pan(0.3f); // slightly right
// Mono to stereo:
auto [left, right] = panner.process(mono_sample);
// Stereo balance adjustment:
float l = left_sample, r = right_sample;
panner.process(l, r); // modifies in-place
| Method | Description |
|---|---|
set_pan(float) |
Set pan position. Clamped to [-1, 1]. |
pan() |
Current pan value. |
process(float) |
Pan mono to stereo, returns StereoSample{left, right}. |
process(float& left, float& right) |
Adjust stereo balance in-place. |
Uses cosine/sine panning law for constant power.
Sample rate dependency: None.
2. Envelopes¶
Adsr¶
ADSR envelope generator. Call note_on() / note_off(), then next() per sample. Supports retriggering (does not reset level on note_on, ramps from current position).
signal::Adsr env;
env.set_sample_rate(sample_rate);
env.set_params({
.attack = 0.01f, // seconds
.decay = 0.1f, // seconds
.sustain = 0.7f, // level 0-1
.release = 0.3f, // seconds
});
env.note_on();
for (int i = 0; i < num_samples; ++i)
output[i] = osc.next() * env.next();
| Method | Description |
|---|---|
set_params(Adsr::Params) |
Set ADSR times and sustain level. |
set_sample_rate(float) |
Set sample rate (required). |
note_on() |
Trigger attack stage. |
note_off() |
Trigger release stage. |
reset() |
Reset to idle, level = 0. |
next() |
Return next envelope sample (0-1). |
is_active() |
True if not idle. |
stage() |
Current stage: idle, attack, decay, sustain, release. |
Params struct:
| Field | Default | Unit | Range |
|---|---|---|---|
attack |
0.01 | seconds | > 0 |
decay |
0.1 | seconds | > 0 |
sustain |
0.7 | level | 0-1 |
release |
0.3 | seconds | > 0 |
Setting attack/decay/release to 0 causes instant transitions (rate = 1.0).
Sample rate dependency: set_sample_rate() must be called before use.
Caveat: Fixed-state and allocation-free. Configure parameters and sample
rate from prepare() or control code; note_on(), note_off(), next(),
buffer application, reset(), and accessors are real-time safe.
3. Oscillators¶
This section covers only the legacy signal::Oscillator below. For the
newer VA/VCO/DCO/wavetable suite (pulp::signal::osc, under
core/signal/include/pulp/signal/osc/) — analog-modeled shapes, a
divider-clocked DCO, and two wavetable tiers — see the
oscillators guide. The two are separate, unrelated class
families; prefer the suite for new work.
Oscillator¶
Band-limited oscillator with polyBLEP anti-aliasing. Supports sine, saw, square, and triangle waveforms.
signal::Oscillator osc;
osc.set_sample_rate(sample_rate);
osc.set_waveform(signal::Oscillator::Waveform::saw);
osc.set_frequency(440.0f);
for (int i = 0; i < num_samples; ++i)
output[i] = osc.next();
| Method | Description |
|---|---|
set_sample_rate(float) |
Set sample rate. |
set_frequency(float hz) |
Set oscillator frequency. |
set_waveform(Waveform) |
Set waveform type. |
reset() |
Reset phase to 0. |
next() |
Generate next sample. |
phase() |
Current phase (0-1). |
frequency() |
Current frequency. |
Waveforms: sine, saw, square, triangle.
PolyBLEP anti-aliasing reduces aliasing artifacts on saw, square, and triangle waveforms. Triangle is generated via leaky integration of a polyBLEP square wave.
Sample rate dependency: set_sample_rate() must be called. Output range is approximately [-1, 1].
Caveat: The triangle waveform uses a leaky integrator, which needs a few cycles to stabilize after reset() or frequency changes.
4. Filters¶
Biquad¶
Standard second-order IIR filter (biquad). Supports 8 filter types. Uses Direct Form II Transposed implementation.
signal::Biquad lpf;
lpf.set_coefficients(signal::Biquad::Type::lowpass, 2000.0f, 0.707f, sample_rate);
// Per-sample:
float out = lpf.process(input_sample);
// Per-buffer:
lpf.process(buffer, num_samples); // in-place
| Method | Description |
|---|---|
set_coefficients(type, freq_hz, q, sample_rate, gain_db) |
Configure filter. gain_db only used for peaking/shelf types. |
process(float) |
Filter one sample, returns output. |
process(float*, int) |
Filter buffer in-place. |
reset() |
Clear internal state (call on discontinuities). |
Filter types:
| Type | Use | gain_db used? |
|---|---|---|
lowpass |
Low-pass filter | No |
highpass |
High-pass filter | No |
bandpass |
Band-pass filter | No |
notch |
Notch (band-reject) | No |
allpass |
All-pass filter | No |
peaking |
Parametric EQ band | Yes |
low_shelf |
Low shelf EQ | Yes |
high_shelf |
High shelf EQ | Yes |
Parameters:
- freq_hz: Center/cutoff frequency in Hz
- q: Quality factor (0.707 = Butterworth, higher = more resonant)
- gain_db: Boost/cut in dB (peaking and shelf types only)
Sample rate dependency: sample_rate is a parameter of set_coefficients(). Call again if sample rate changes.
Six-band parametric EQ¶
SixBandEq promotes the EQ Curve demo's fixed console layout into a reusable
process primitive: low shelf, four peaking bands, and high shelf. SixBandEq64
provides the same contract for double samples. Both aliases have two independent
channels; use SixBandEqT<SampleType, Channels> for a different compile-time
channel count.
#include <pulp/signal/six_band_eq.hpp>
signal::SixBandEq eq;
eq.prepare(sample_rate);
eq.set_transition_samples(64); // Optional; zero keeps immediate legacy updates.
eq.set_band(2, {.frequency_hz = 900.0f, .gain_db = -4.0f, .q = 2.0f});
// Planar stereo callback:
eq.process_block(left, frames, 0);
eq.process_block(right, frames, 1);
// During a transition, the plot describes the requested destination.
std::array<float, 256> curve_db;
eq.response_curve_db(20.0, 20000.0, curve_db);
The plain control domains are frequency
[20, min(20000, 0.49 * sample_rate)] Hz, gain [-18, 18] dB, and Q
[0.1, 12]. Finite values clamp to those bounds. Non-finite values select the
band's default. An invalid or too-low sample rate selects 48 kHz. The established
defaults are 80, 250, 700, 2000, 5000, and 12000 Hz; all gains are 0 dB; Q is
0.707, 1.0, 1.2, 1.2, 1.0, and 0.707.
set_band() is allocation-free and is safe to call between blocks for host
automation. By default, transition_samples() is zero: changes swap
coefficients immediately and preserve the legacy demo output. Set a non-zero
length to crossfade two complete, stable cascades without interpolating
recursive coefficients. That temporarily doubles the EQ processing work only
while a fade is active. Use set_bands() to apply several changed controls as
one atomic destination at a block boundary. A request whose sanitized controls
exactly match the current request is a state-preserving no-op, so hosts may send
unchanged values without keeping the transition path active or doubling CPU.
Several changes made before a fade processes its first sample update that same
destination. Changes arriving after it starts are coalesced into the latest
destination for one subsequent fade. Each channel advances independently as it
is processed. set_transition_samples() configures future scheduling only;
assigning the current length is an exact no-op, and a fade or queued
continuation already in flight retains its duration and warmed state. Selecting
zero therefore does not cold-retune or cancel an audible fade. If another
control request arrives before that fade finishes, it uses one bounded queued
continuation before subsequent requests become immediate. reset() clears both
cascades' history without changing controls or transition progress. During a
fade, coefficients(), magnitude(),
magnitude_db(), and response_curve_db() describe the requested destination,
not the instantaneous crossfade. The class owns no parameters and registers no
host or runtime controls; the processor remains the single authority for
mapping its state into the EQ.
SOS cascade¶
SosCascadeT executes the coefficient vectors returned by FilterDesign and
IirDesign without turning a high-order filter into one numerically fragile
direct form. Storage and processing are bounded by the template capacity; the
smaller runtime capacity is selected once during preparation.
auto designed = signal::IirDesign::elliptic_lowpass(
8, 2000.0f, 0.5f, 60.0f, sample_rate);
signal::SosCascadeT<float, 8> filter;
filter.prepare(4); // prepared capacity: four SOS, or eighth order
if (filter.set_coefficients(std::span{designed}) !=
signal::SosCascadeInstallStatus::installed) {
// The previous complete cascade and its recursive state remain active.
}
filter.process(buffer, num_samples);
Installation rejects the whole candidate if any section is non-finite,
unstable, or beyond the prepared capacity. No prefix is installed. The default
transition is an immediate coefficient switch plus recursive-state reset. Pass
SosCascadeTransition::preserve_state explicitly to keep tails for sections
that remain at the same ordinal; new and removed ordinals are cleared. Neither
policy crossfades, so install at a block boundary. Section order is preserved
because it controls internal headroom even though ideal cascade transfer
functions commute. An empty cascade is a defined identity/bypass.
Design helpers that return std::vector still belong on a control or prepare
thread. Once the vector exists, prepare, installation, process, and reset
on SosCascadeT allocate no memory.
Svf¶
State Variable Filter using Topology Preserving Transform (TPT). Numerically stable at all frequencies with no Nyquist cramping. Provides simultaneous lowpass, highpass, bandpass, and notch outputs (one selected via mode).
signal::Svf filter;
filter.set_sample_rate(sample_rate);
filter.set_frequency(1000.0f);
filter.set_resonance(2.0f);
filter.set_mode(signal::Svf::Mode::lowpass);
for (int i = 0; i < num_samples; ++i)
output[i] = filter.process(input[i]);
| Method | Description |
|---|---|
set_sample_rate(float) |
Set sample rate. Recalculates coefficients. |
set_frequency(float hz) |
Set cutoff frequency. Recalculates coefficients. |
set_resonance(float q) |
Set Q factor (default 0.707). Recalculates coefficients. |
set_mode(Mode) |
Select output: lowpass, highpass, bandpass, notch. |
process(float) |
Filter one sample. |
process(float*, int) |
Filter buffer in-place. |
reset() |
Clear internal state. |
Sample rate dependency: set_sample_rate() required. Coefficients update automatically when frequency, resonance, or sample rate change.
LadderFilter¶
Moog-style 4-pole (24 dB/oct) ladder filter with nonlinear feedback. Self-oscillates at high resonance.
signal::LadderFilter ladder;
ladder.set_sample_rate(sample_rate);
ladder.set_frequency(800.0f);
ladder.set_resonance(0.8f); // 0-1, high values self-oscillate
for (int i = 0; i < num_samples; ++i)
output[i] = ladder.process(input[i]);
| Method | Description |
|---|---|
set_sample_rate(float) |
Set sample rate. |
set_frequency(float hz) |
Set cutoff frequency. |
set_resonance(float) |
Set resonance. Clamped to [0, 1]. Values near 1 self-oscillate. |
process(float) |
Filter one sample. |
process(float*, int) |
Filter buffer in-place. |
reset() |
Clear all stage states. |
Uses tanh() saturation per stage for analog-style nonlinearity. The feedback path creates resonance: resonance * 4.0 * (stage[3] - input * 0.5).
Sample rate dependency: set_sample_rate() required.
Caveat: High resonance with hot input signals can produce loud self-oscillation. Consider a limiter on the output.
Analog VCF¶
For measured Juno, Jupiter-8, Prophet-5, and Minimoog panel voicings—or for building a custom voicing directly on Pulp's nonlinear four-pole OTA cascade— see the Analog VCF guide. It documents control ranges, real-time use, oversampling latency, graph PDC, and both public APIs.
LinkwitzRiley¶
4th-order Linkwitz-Riley crossover filter (-6 dB at crossover frequency). Provides lowpass and highpass outputs that sum flat. Built from two cascaded Biquad lowpass and two cascaded Biquad highpass filters.
signal::LinkwitzRiley xover;
xover.set_frequency(2000.0f, sample_rate);
for (int i = 0; i < num_samples; ++i) {
auto [low, high] = xover.process(input[i]);
low_band[i] = low;
high_band[i] = high;
}
| Method | Description |
|---|---|
set_frequency(float hz, float sample_rate) |
Set crossover frequency. |
process(float) |
Split one sample, returns BandSplit{low, high}. |
reset() |
Clear all filter states. |
Sample rate dependency: sample_rate is a parameter of set_frequency().
5. Delays¶
DelayLine¶
Variable-length delay line with linear interpolation for fractional delays. Must call prepare() before use (allocates the internal buffer).
signal::DelayLine delay;
// In prepare():
int max_delay = static_cast<int>(sample_rate * 0.5f); // 500ms max
delay.prepare(max_delay);
// In process():
delay.push(input_sample);
float delayed = delay.read(delay_in_samples);
// Or combined:
float out = delay.process(input_sample, delay_in_samples);
| Method | Description |
|---|---|
prepare(int max_delay_samples) |
Allocate buffer. Not real-time safe. |
push(float) |
Write a sample into the delay line. |
read(float delay_samples) |
Read at fractional delay with linear interpolation. |
read(int delay_samples) |
Read at integer delay. |
process(float input, float delay_samples) |
Push and read in one call. |
reset() |
Zero the buffer. |
max_delay() |
Maximum delay in samples. |
Sample rate dependency: Convert time to samples manually (seconds * sample_rate).
Caveat: prepare() allocates memory. Call it in Processor::prepare(), never on the audio thread.
CharacterDelay¶
Stereo, wet-only delay with clean, vintage-digital, tape, BBD, and diffusion processors inside the feedback loop. It includes reverse, freeze, ducking, crossfeed, per-character time slew, and a physical tape tier. See the Character Delay guide for lifecycle, complete per-method API documentation, character selection, and recipes.
Chorus¶
Stereo chorus using modulated delay lines. Produces stereo output from mono input with 90-degree LFO phase offset between channels.
signal::Chorus chorus;
// In prepare():
chorus.prepare(sample_rate);
// In process():
chorus.set_rate(1.5f); // LFO rate in Hz
chorus.set_depth(0.5f); // modulation depth 0-1
chorus.set_mix(0.5f); // dry/wet 0-1
chorus.set_delay_ms(15.0f); // center delay
auto [left, right] = chorus.process(mono_input);
| Method | Description |
|---|---|
prepare(float sample_rate) |
Allocate delay buffers (50ms max). Not real-time safe. |
set_rate(float hz) |
LFO rate. Typical range: 0.1-5 Hz. |
set_depth(float) |
Modulation depth [0, 1]. |
set_mix(float) |
Dry/wet mix [0, 1]. |
set_delay_ms(float) |
Center delay time. Typical range: 5-30 ms. |
process(float) |
Process mono input, returns StereoSample{left, right}. |
reset() |
Clear delay buffers and reset LFO phase. |
Sample rate dependency: prepare() must be called with the current sample rate.
Phaser¶
Phaser effect using cascaded first-order allpass filters with LFO modulation and feedback.
signal::Phaser phaser;
phaser.set_sample_rate(sample_rate);
phaser.set_rate(0.5f); // LFO Hz
phaser.set_depth(0.7f); // 0-1
phaser.set_feedback(0.5f); // -0.95 to 0.95
phaser.set_stages(4); // 2-8 stages
phaser.set_mix(0.5f);
// Per-sample:
float out = phaser.process(input_sample);
// Per-buffer:
phaser.process(buffer, num_samples);
| Method | Description |
|---|---|
set_sample_rate(float) |
Set sample rate. |
set_rate(float hz) |
LFO rate. |
set_depth(float) |
Modulation depth [0, 1]. Maps LFO to 200-5000 Hz range. |
set_feedback(float) |
Feedback amount. Clamped to [-0.95, 0.95]. |
set_mix(float) |
Dry/wet mix [0, 1]. |
set_stages(int) |
Number of allpass stages. Clamped to [2, 8]. |
process(float) |
Process one sample. |
process(float*, int) |
Process buffer in-place. |
reset() |
Clear all state. |
Sample rate dependency: set_sample_rate() required.
6. Dynamics¶
Compressor¶
Feed-forward compressor with soft knee, adjustable attack/release, and makeup gain.
signal::Compressor comp;
comp.set_sample_rate(sample_rate);
comp.set_params({
.threshold_db = -20.0f,
.ratio = 4.0f,
.attack_ms = 5.0f,
.release_ms = 100.0f,
.knee_db = 6.0f,
.makeup_db = 0.0f,
});
for (int i = 0; i < num_samples; ++i)
output[i] = comp.process(input[i]);
float gr = comp.gain_reduction_db(); // for metering
| Method | Description |
|---|---|
set_params(Compressor::Params) |
Set all compressor parameters. |
set_sample_rate(float) |
Set sample rate. |
process(float) |
Compress one sample. |
process(float*, int) |
Compress buffer in-place. |
gain_reduction_db() |
Current gain reduction (for meters). |
reset() |
Reset envelope to 0. |
Params struct:
| Field | Default | Unit | Description |
|---|---|---|---|
threshold_db |
-20.0 | dB | Level where compression begins |
ratio |
4.0 | :1 | Compression ratio |
attack_ms |
5.0 | ms | Attack time |
release_ms |
100.0 | ms | Release time |
knee_db |
6.0 | dB | Soft knee width (0 = hard knee) |
makeup_db |
0.0 | dB | Output makeup gain |
Sample rate dependency: set_sample_rate() required (used for attack/release coefficients).
Limiter¶
Brickwall limiter with instant attack and configurable release. Lookahead-free design.
signal::Limiter limiter;
limiter.set_sample_rate(sample_rate);
limiter.set_threshold_db(-1.0f);
limiter.set_release_ms(50.0f);
for (int i = 0; i < num_samples; ++i)
output[i] = limiter.process(input[i]);
| Method | Description |
|---|---|
set_threshold_db(float) |
Set ceiling in dB. Converted to linear internally. |
set_release_ms(float) |
Release time in ms. |
set_sample_rate(float) |
Set sample rate. |
process(float) |
Limit one sample. |
process(float*, int) |
Limit buffer in-place. |
reset() |
Reset envelope. |
Default threshold: 0 dB (unity). Default release: 50 ms.
Sample rate dependency: set_sample_rate() required.
NoiseGate¶
Noise gate / downward expander. Attenuates signal below threshold with configurable expansion ratio and range limit.
signal::NoiseGate gate;
gate.set_sample_rate(sample_rate);
gate.set_params({
.threshold_db = -40.0f,
.ratio = 10.0f, // 10:1 expansion
.attack_ms = 0.5f,
.release_ms = 50.0f,
.range_db = -80.0f, // max attenuation
});
for (int i = 0; i < num_samples; ++i)
output[i] = gate.process(input[i]);
| Method | Description |
|---|---|
set_params(NoiseGate::Params) |
Set gate parameters. |
set_sample_rate(float) |
Set sample rate. |
process(float) |
Gate one sample. |
process(float*, int) |
Gate buffer in-place. |
reset() |
Reset envelope. |
Params struct:
| Field | Default | Unit | Description |
|---|---|---|---|
threshold_db |
-40.0 | dB | Gate opens above this level |
ratio |
10.0 | :1 | Expansion ratio (higher = harder gate) |
attack_ms |
0.5 | ms | How fast the gate closes |
release_ms |
50.0 | ms | How fast the gate opens |
range_db |
-80.0 | dB | Maximum attenuation floor |
Sample rate dependency: set_sample_rate() required.
DeEsser¶
Split-band sibilance control with an independent band-pass detector. The LR4 audio split recombines at flat magnitude when no reduction is active; only the high band is attenuated when detector level exceeds the threshold.
signal::DeEsser de_esser;
de_esser.prepare(sample_rate, {
.threshold_db = -24.0f,
.range_db = 12.0f,
.attack_ms = 1.0f,
.release_ms = 80.0f,
.detector_frequency_hz = 6500.0f,
.detector_q = 1.5f,
.split_frequency_hz = 4500.0f,
});
de_esser.process(buffer, num_samples);
| Method | Description |
|---|---|
prepare(sample_rate, params) |
Validate configuration and establish coefficients. |
set_params(params) |
Adopt one complete valid configuration; invalid input leaves the current configuration unchanged. |
process(sample) / process(buffer, count) |
Process mono samples with fixed state and no allocation. |
set_bypassed(bool) |
Select sample-exact dry output while keeping detector and split state current. |
set_output_mode(OutputMode::detector_listen) |
Audition the exact band driving gain reduction. |
gain_reduction() |
Read the shared non-negative dynamics telemetry value. |
reset() |
Clear detector and split history without changing configuration. |
range_db is a non-negative maximum attenuation magnitude. Use one instance
per mono channel. A stereo plugin should choose its channel-link policy
explicitly rather than assuming independent or max-linked detection.
Attack and release use the shared envelope follower's exact 10-to-90-percent
time convention.
Sample rate dependency: prepare() is required. Coefficient-changing
configuration calls belong outside the audio callback; the prepared process,
bypass/listen, telemetry, and reset paths are allocation-free.
7. Effects¶
Reverb¶
Feedback Delay Network (FDN) reverb with 4 delay lines, Hadamard mixing matrix, and one-pole damping filters. Produces stereo output from mono input.
signal::Reverb reverb;
// In prepare():
reverb.prepare(sample_rate);
// In process():
reverb.set_decay(2.0f); // RT60 in seconds
reverb.set_damping(0.3f); // high-frequency damping 0-0.99
reverb.set_mix(0.3f); // dry/wet 0-1
auto [left, right] = reverb.process(mono_input);
| Method | Description |
|---|---|
prepare(float sample_rate) |
Allocate delay buffers. Not real-time safe. |
set_decay(float seconds) |
Reverb tail time (RT60). |
set_damping(float) |
High-frequency damping [0, 0.99]. Higher = darker. |
set_mix(float) |
Dry/wet mix [0, 1]. |
process(float) |
Process mono input, returns StereoSample{left, right}. |
reset() |
Clear all delay lines and filter states. |
Uses prime-number delay lengths (1087, 1283, 1481, 1693 samples at 44.1 kHz, scaled for other rates) for maximum echo density. Feedback gain is derived from RT60: 10^(-3 * avg_delay / decay).
Sample rate dependency: prepare() must be called with the current sample rate. Delay lengths scale proportionally.
FdnReverb¶
FdnReverb is the true-stereo, wet-only multirate reverb for richer production
use: 16 delay lines, five data-driven modes, shimmer, modulation, Bloom,
in-loop drive, and a selectable 16–96 kHz internal tank rate. It has a stronger
reset/rate-switch contract and a larger prepared memory footprint than the
compact Reverb above.
Include <pulp/signal/fdn_reverb.hpp> directly. See the
Multirate FDN Reverb guide for controls, lifecycle rules,
module ownership, finite-state recovery, and focused validation.
WaveShaper¶
Waveshaping distortion with five curve types.
signal::WaveShaper shaper;
shaper.set_curve(signal::WaveShaper::Curve::tanh_clip);
shaper.set_drive(3.0f);
// Per-sample:
float out = shaper.process(input_sample);
// Per-buffer:
shaper.process(buffer, num_samples);
| Method | Description |
|---|---|
set_curve(Curve) |
Select waveshaping function. |
set_drive(float) |
Input gain multiplier before shaping. |
process(float) |
Shape one sample. |
process(float*, int) |
Shape buffer in-place. |
Curves:
| Curve | Formula | Character |
|---|---|---|
soft_clip |
x / (1 + |x|) |
Gentle saturation |
hard_clip |
clamp(x, -1, 1) |
Digital clipping |
tanh_clip |
tanh(x) |
Smooth analog-style saturation |
fold |
Fold-back at +/- 1 | Wave folding |
sine_fold |
sin(x * pi/2) |
Sinusoidal wave folding |
Default: tanh_clip, drive = 1.0.
Sample rate dependency: None. Consider using Oversampler to reduce aliasing from nonlinear shaping.
Oversampler¶
Runs a processing callback at 2x, 4x, 8x, or 16x sample rate with anti-aliasing filters on input and output.
signal::Oversampler os;
os.set_factor(signal::Oversampler::Factor::x2);
os.set_sample_rate(sample_rate);
os.set_kind(signal::Oversampler::Kind::linear_phase_fir);
os.set_quality(signal::Oversampler::Quality::pristine);
signal::WaveShaper shaper;
shaper.set_drive(4.0f);
for (int i = 0; i < num_samples; ++i) {
output[i] = os.process(input[i], [&](float s) {
return shaper.process(s);
});
}
// Or process a contiguous block with the same callback:
os.process_block(input, output, num_samples, [&](float s) {
return shaper.process(s);
});
| Method | Description |
|---|---|
set_factor(Factor) |
Factor::x2, Factor::x4, Factor::x8, or Factor::x16. |
set_sample_rate(float) |
Set base sample rate. Configures anti-aliasing filters. |
set_kind(Kind) |
Select the legacy Biquad, minimum-phase polyphase IIR, or linear-phase FIR lane. |
set_quality(Quality) |
Select standard (96 dB prototype, flat to 80% of Nyquist) or pristine (140 dB prototype, flat to 90% of Nyquist) for the linear-phase lane. |
latency() |
Return host-rounded and exact base-rate latency plus whether the delay is constant. |
latency_samples() |
Return the integer base-rate delay for Processor::latency_samples(). |
process(float, callback) |
Upsample, process via callback, downsample. |
process_block(const float*, float*, size_t, callback) |
Process a contiguous block; input and output may alias. |
reset() |
Clear filter states. |
The default anti-aliasing lane cascades one Butterworth Biquad pair per 2x stage, each cutting at 0.4 * its own stage's input rate — the boundary that stage's decimation actually folds around. Kind::polyphase_iir uses cascaded half-band allpass stages. Both are low-latency minimum-phase choices, but their group delay varies with frequency and therefore cannot truthfully expose one exact compensation value. When you need the actual delay of a minimum-phase lane rather than a scalar it cannot honestly provide, measure the curve: the offline Audio Doctor's measure_group_delay() (test/support/audio_doctor.hpp) reports unwrapped phase and group delay in samples or seconds per frequency, and reports a stopband as undefined instead of inventing a number for it. See the audio-harness skill.
Be clear-eyed about what the Biquad lane buys you: a single biquad cannot reject much near the fold boundary, so its worst-case alias rejection is only about 7 dB (measured just above base Nyquist, improving to ~9 dB by 0.55 of the base rate), and its passband is already about -1.9 dB at 0.3 of the base rate. Those figures are the same at every factor — raising the factor gives the callback's own harmonics more room before they fold, it does not give you a better filter. (Nor should it cost you tone: a chain that lowpassed harder at each added stage would post a flattering rejection number while quietly muffling the top of the band, so the regression suite pins the passband and the rejection together.) When the anti-aliasing itself has to be good, use polyphase_iir or linear_phase_fir — not a higher factor on the Biquad lane.
Kind::linear_phase_fir is the host-compensated quality lane. Its final decimation stage has a transition band ending at the base-rate Nyquist; Quality::standard uses a 96 dB prototype with a passband edge at 80% of Nyquist, while Quality::pristine uses a 140 dB prototype with a passband edge at 90%. The realized numeric floor also depends on SampleType: the regression suite requires at least 120 dB rejection from the default float alias and 130 dB from Oversampler64 (measured on the reference stopband tone at about 141 dB and 161 dB respectively). Higher-rate filters preserve only the base band that can reach the final output, which keeps ×8/×16 practical without weakening the final decimator's measured rejection. The fixed tap counts make the exact delay integral at every supported factor:
| Factor | standard latency |
pristine latency |
|---|---|---|
| 2x | 64 samples | 192 samples |
| 4x | 76 samples | 209 samples |
| 8x | 80 samples | 216 samples |
| 16x | 82 samples | 219 samples |
These values are base-rate samples and do not depend on sample rate or host block size. A processor using the linear-phase lane should return os.latency_samples() from its own Processor::latency_samples(). Factor, kind, and quality changes reconfigure and reset filter state; perform them in prepare() or while rendering is stopped, then use the framework's latency-change notification if a live control changes the reported value.
process() and process_block() use a templated callback and fixed internal scratch storage. After construction/configuration, they do not allocate on the audio thread as long as the callback does not allocate. That holds for a std::function callback too — it is taken by reference, never copied per sample — though it still costs an indirect call, so hot paths should hand these methods a lambda directly. Configure factor, sample rate, and kind from prepare() or a control thread; those setters reset or reconfigure filter state.
The oversampler reports but does not remove its delay. For linear-phase processing, report that delay to the host and align any internal dry path. Pulp's latency-proof harness renders an identity path through every factor and quality, proves the measured delay matches the report, and pins the intended values so a tap-count refactor cannot silently add latency. Minimum-phase modes deliberately do not claim a constant delay; use a frequency-aware phase strategy when parallel alignment is required.
Sample rate dependency: set_sample_rate() required.
Drum output-stage oversampling¶
Every pulp::signal::drum voice reaches the same OutputStage for wavefolding,
tanh drive, word-length reduction, sample-rate reduction, dead-zone shaping,
and final level. That shared stage owns the drum-specific quality policy:
OutputOversampling::x2is the default. It wraps the nonlinear and lo-fi chain in the established 65-tap, 81.3 dB Kaiser half-band pair used by character-delay hysteresis and adds exactly 32 host samples.OutputOversampling::x4cascades a second house pair for hard fold/drive settings and adds exactly 48 host samples.OutputOversampling::bypassdeliberately runs the chain at the host rate, adds no latency, and preserves the aliasing of period-authentic digital drum machines.
OutputStage::latency_samples() reports the selected constant.
OutputStage::latency_samples_for() exposes the same contract at compile time
for processors that select a fixed quality without owning a prepared stage.
Voice::latency_samples() makes the same report available through registry-
constructed engines without recovering their concrete types. Kit publishes
one latency for its summed output and applies one quality to every registered
voice before triggering or rendering, so layered voices cannot drift apart
when a caller retains and reconfigures a concrete voice. Existing custom
Voice subclasses remain source-compatible: their default quality contract is
zero-latency bypass, and registering one makes the whole kit fall back to that
common aligned quality. A processor mixing a voice or kit with an undelayed
parallel path must compensate the dry path or report that latency to its host.
Voices keep rendering while the FIR owns delayed samples, so their final filter
tail is drained rather than cut.
prepare() creates the FIR storage; quality changes and the audio path are
allocation-free, but a quality change resets the filter and lo-fi clock and
therefore belongs outside the audio callback unless a kit is restoring its
already-selected factor through the idempotent setter.
Drum voices expose additive mono process() and additive
process_stereo(). The default stereo realization is centered and its two
channels sum exactly to the mono render. ClapVoice specializes that hook:
successive bursts alternate left and right according to set_stereo_width(),
while its room tail and optional tonal body remain centered. Both paths retain
the base voice's allocation-free choke fade.
Resampling Helpers¶
Resampler is the arbitrary-ratio polyphase FIR sample-rate converter.
SincResampler is a table-driven fractional-delay sinc interpolator used when
callers already own the source-buffer traversal.
Resampler::prepare() and SincResampler::build() allocate filter tables and
delay/scratch storage. Run them outside the audio callback. After setup,
Resampler::set_ratio(), reset(), process_block*(), max_output_for(),
and accessors are allocation-free with caller-owned input/output buffers.
SincResampler::apply() and read() are allocation-free after build().
8. Analysis¶
Fft¶
Radix-2 in-place FFT (decimation-in-time). Size must be a power of 2. Pre-computes twiddle factors at construction.
signal::Fft fft(1024);
std::vector<std::complex<float>> freq(1024);
fft.forward_real(audio_buffer, freq.data());
std::vector<float> magnitudes(512);
fft.magnitude_db(freq.data(), magnitudes.data(), 512);
| Method | Description |
|---|---|
Fft(int size) |
Construct with FFT size (must be power of 2). |
size() |
FFT size. |
forward(complex<float>*) |
In-place forward FFT. |
inverse(complex<float>*) |
In-place inverse FFT (with 1/N scaling). |
forward_real(float* in, complex<float>* out) |
Real input to complex frequency domain. |
magnitude_db(complex<float>*, float*, int) |
Compute magnitude spectrum in dB. |
magnitude(complex<float>*, float*, int) |
Compute magnitude spectrum (linear). |
Sample rate dependency: None (operates in sample domain). To convert bin index to frequency: bin * sample_rate / fft_size.
Caveat: The constructor allocates twiddle factor storage. Create Fft objects in prepare(), not on the audio thread. forward(), inverse(), forward_real(), and magnitude helpers are real-time safe after construction when callers provide output buffers.
Convolver¶
Frequency-domain convolver using overlap-add. Loads an impulse response and convolves input signal in blocks.
signal::Convolver conv;
// In prepare() — load IR (allocates):
conv.load_ir(ir_data, ir_length, 256); // block_size = 256
// In process():
for (int i = 0; i < num_samples; ++i)
output[i] = conv.process(input[i]);
// Or buffer-based:
conv.process(input_buf, output_buf, num_samples);
| Method | Description |
|---|---|
load_ir(float* ir, int length, int block_size) |
Load impulse response. Block size defaults to 256 if 0. Not real-time safe. |
process(float) |
Convolve one sample. Returns output. |
process(float* in, float* out, int n) |
Convolve a buffer. |
reset() |
Clear all internal buffers. |
The FFT size is automatically chosen as the next power of 2 >= block_size + ir_length. Processing happens in blocks: samples accumulate until block_size is reached, then a full FFT-multiply-IFFT cycle runs.
Sample rate dependency: The IR itself is sample-rate dependent. If you change sample rate, reload the IR at the new rate.
Caveat: load_ir() allocates multiple buffers. Call only in prepare(). The per-sample process() is real-time safe (no allocations).
WindowFunction¶
Generates window functions for FFT analysis, spectral processing, and FIR filter design.
auto window = signal::WindowFunction::generate(1024, signal::WindowFunction::Type::hann);
// Apply to a buffer before FFT:
signal::WindowFunction::apply(audio_buffer, window);
fft.forward_real(audio_buffer, freq.data());
| Method | Description |
|---|---|
generate(int size, Type, float param) |
Generate a window. Returns std::vector<float>. param used only for Kaiser. |
apply(float* buffer, vector<float>& window) |
Multiply buffer by window in-place. |
Window types:
| Type | Use case |
|---|---|
rectangular |
No windowing (unity) |
hann |
General-purpose spectral analysis |
hamming |
FIR filter design |
blackman |
High dynamic range spectral analysis |
blackman_harris |
Minimum four-term Blackman-Harris window for approximately −92 dB peak sidelobes |
blackman_nuttall |
Four-term Blackman-Nuttall window for approximately −98 dB peak sidelobes |
flat_top |
Amplitude-accurate measurements |
kaiser |
Adjustable main-lobe/side-lobe tradeoff (set param = beta, default 3.0) |
The four-term coefficients follow the published cosine-sum families described by Harris and Nuttall. Both trade a wider main lobe for a substantially lower leakage floor than Hann or ordinary Blackman.
Sample rate dependency: None.
Caveat: generate() allocates a vector. Call during prepare(), not on the audio thread. apply() is real-time safe when passed a precomputed window.
MirroredHistoryBuffer¶
MirroredHistoryBuffer<T> keeps a fixed number of recent samples physically
contiguous even when its write cursor wraps. The returned window is always
ordered oldest to newest, which lets FIR, resampling, and spectral processors
walk history without a per-element modulo operation or a wrap-time move.
signal::MirroredHistoryBuffer<float> history;
history.prepare(1024); // allocates two mirrored copies
// In process(): fixed two-write cost, no allocation.
history.push(input_sample);
std::span<const float> oldest_to_newest = history.window();
| Method | Description |
|---|---|
prepare(size_t capacity) |
Allocate and zero fixed mirrored storage. Not real-time safe. Zero capacity is valid. |
push(T sample) |
Append one sample with deterministic work independent of wrap position. |
window() |
Return a zero-copy contiguous view of the complete oldest-to-newest history. |
reset() |
Restore zero-filled history without changing capacity. |
This is single-thread DSP history, not a producer/consumer queue. Do not use it
for communication between the audio, UI, or worker threads; use the runtime
SPSC publication primitives for those ownership boundaries. After prepare(),
push(), window(), and reset() are allocation-free.
Spectrogram helpers¶
ColorMapper and FrequencyAxis are fixed-state utilities for display-side spectral mapping. Their mapping methods are allocation-free. SpectrogramBuffer::configure() allocates pixel storage, so configure it off the audio thread; push_column() is allocation-free after configuration and can be used in bounded realtime or UI-adjacent analysis paths with caller-owned magnitude data.
Filter and polynomial design helpers¶
Scalar FilterDesign biquad helpers return fixed-size coefficient structs and do not allocate, but they are intended for prepare/control-rate retuning because applying coefficients can reset processor state. Butterworth, Chebyshev, elliptic, and polynomial helpers that return std::vector allocate result storage and belong on prepare/control/offline threads.