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.
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.
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.
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.
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.
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 |
flat_top |
Amplitude-accurate measurements |
kaiser |
Adjustable main-lobe/side-lobe tradeoff (set param = beta, default 3.0) |
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.
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.