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/.
Performance¶
- Block math goes through
pulp::simd. Sums, dot products, peak and energy reductions, gain ramps and FIR correlation are compiled kernels (Accelerate on Apple, Highway with run-time AVX2/AVX-512/NEON dispatch elsewhere). A plain C++ reduction loop stays scalar under Pulp's IEEE build flags, sopulp::simd::dotover 256 taps is roughly 20x faster than the loop it replaces. Short elementwise loops (gain, mix, copy) are better written inline: they auto-vectorize and skip the call. See modules: simd. - Keep history linear for FIR work. A wrapping ring buffer forces a
branch or mask per tap; a linear window of
taps - 1 + blocksamples lets onecorrelate()produce the whole block.FirFilterTworks this way:process(buffer, n)is onecorrelate()per block andprocess(sample)onedot()per sample, which measured about 30x (64 taps) and 40x (256 taps) faster than its former ring-buffer loop on Apple silicon. The linear-phaseOversamplerlane and the drum output stage run on it. - Recursive filters vectorize across channels or voices, not time. A
biquad or SVF cannot be vectorized along its own output, but N independent
instances stored struct-of-arrays (one array per coefficient and state
variable, the instance index innermost) auto-vectorize across the N lanes:
four such lanes measure about 5x cheaper per channel than four separate
Biquadobjects. - Update coefficients at control rate. Recomputing an SVF's
tan()every sample in every voice costs more than the filter; once per block is usually inaudible. - Measure with
pulp-dsp-throughput-benchmark(guide) before and after, in Release.
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.
TempoDelayWrapper¶
TempoDelayWrapper owns the retained stereo history and feedback route for a
musical delay. Prepare it once at the maximum addressable delay, then publish a
sample or BeatDivision target before processing the next block:
#include <pulp/signal/tempo_delay_wrapper.hpp>
pulp::signal::TempoDelayWrapper delay;
delay.prepare(sample_rate, max_delay_samples);
delay.set_tempo(pulp::timebase::BeatDivision::Eighth, 120.0);
delay.set_feedback(0.65);
delay.set_crossfeed(0.25);
delay.process(left, right, frame_count);
The wrapper supports Lagrange-3 and Lagrange-5 reads. Unsupported interpolation,
nonfinite input, invalid tempo or division, out-of-range delay, and feedback above
0.98 return typed errors. A retime keeps both read heads fixed and crossfades
their outputs over the configured transition, so tempo changes do not sweep a
read head and create pitch glide. reset() clears both histories and cancels an
active retime. It reports zero direct-path latency; with feedback off the tail is
the largest configured delay, while active feedback is unbounded (tail_samples()
returns -1).
Processing is allocation-free and callback-partition invariant after prepare().
The examples/pulp-delay consumer routes its authored free or synchronized times
through this wrapper before applying the selected character coloration.
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.
ParallelDynamicsMixer¶
ParallelDynamicsMixer wraps caller-owned dynamics processing with the pieces
that parallel compression otherwise repeats: true-stereo path alignment, a
declared mix law, output makeup gain, and latency/tail reporting. It does not
own or construct a compressor.
signal::ParallelDynamicsMixer mixer;
mixer.prepare(/* max path latency */ 256, /* max block */ 512);
mixer.configure_latency_and_publish({ // initial setup, while audio is stopped
.wet_mix = 0.35f,
.mix_law = signal::CrossfadeGainLaw::EqualGain,
.output_gain_db = 1.5f,
.dry_latency_samples = 0,
.wet_latency_samples = compressor.latency_samples(),
.dry_tail_samples = 0,
.wet_tail_samples = compressor.tail_samples(),
});
const float* dry[2] = {dry_left, dry_right};
const float* wet[2] = {compressed_left, compressed_right};
float* output[2] = {output_left, output_right};
mixer.process(dry, wet, output, num_samples);
The linear EqualGain law is the default because dry and dynamics-processed
wet signals are normally correlated; identical aligned paths retain unity
gain at every mix. EqualPower is available when the wet path is decorrelated
or deliberately phase-altered. Both laws have exact dry-only and wet-only
endpoints. Output may exactly alias the corresponding dry or wet channel;
partial and cross-channel overlap are rejected.
Gain-, law-, and tail-only configuration publishes atomically at block
boundaries through publish_config(). It rejects latency changes. Use
configure_latency_and_publish() while audio is stopped to change declared
path latency and clear alignment history. latency_samples() reports the
greater declared path latency; tail_samples() includes compensation delay
and reports the active path tail or their maximum while blended.
Caveat: prepare(), publish_config(), and
configure_latency_and_publish() are control-thread operations; latency
configuration additionally requires stopped audio. process() and reset()
allocate no memory after preparation. Consumers own any oversampling around
the wet dynamics processor.
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. |
process_reference(...) |
Same, with exact std::tanh stages (one sample or a buffer). |
process_realtime_efficient(...) |
Same, with the cheaper FastMath::tanh_minimax_recursive (one sample or a buffer). |
reset() |
Clear all stage states. |
Each stage saturates for analog-style nonlinearity. The feedback path creates resonance: resonance * 4.0 * (stage[3] - input * 0.5).
The saturator is a LadderSaturation choice. The default, accurate, is
FastMath::tanh_accurate: within 3.0e-7 of std::tanh (3.6e-7 without fused
multiply-add) and about 2.4x its throughput, keeping the filter's output within
-124 dBFS of the exact-tanh ladder for input up to 0 dBFS (-102 dBFS at
+20 dBFS drive). exact is std::tanh; realtime_efficient is
FastMath::tanh_minimax_recursive, the tanh_minimax fit evaluated in
Estrin form: within 3.8e-6 of std::tanh and slope-matched where it clamps at
|x| = 7, so heavy overdrive keeps its character. The Estrin form's shorter
dependency chain keeps it the cheapest choice inside a polyphonic voice loop,
where the Horner form could run 2.6x slower than accurate.
Select the build default with PULP_SIGNAL_LADDER_SATURATION = 0 (exact),
1 (accurate) or 2 (realtime_efficient), or call process_reference() /
process_realtime_efficient() per call.
For many voices in lockstep (a polyphonic synth), LadderBank<Voices> runs
them as SIMD lanes with output bit-identical to separate filters, several
times faster than a loop over LadderFilter instances.
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.
DiffusionNetwork¶
DiffusionNetworkT is a wet-only, fixed-capacity stereo transient diffuser. It
cascades up to eight Schroeder allpasses and applies an orthonormal
inter-channel rotation after each stage. It is intentionally not a reverb: it
has no feedback tank, decay/damping policy, modulation, early-reflection model,
or dry path.
signal::DiffusionNetwork diffusion;
// Control thread: allocates capacity for every stage and both channels.
if (!diffusion.prepare(sample_rate, 50.0))
return false;
signal::DiffusionNetwork::Config config;
config.stage_count = 4;
config.stages[0] = {.delay_ms = 3.1, .gain = 0.65};
config.stereo_width = 0.75;
if (!diffusion.configure(config))
return false;
// Audio thread: wet-only stereo, including safe in-place blocks.
diffusion.process_block(left, right, left, right, frame_count);
| Method | Description |
|---|---|
prepare(sample_rate, maximum_delay_ms) |
Transactionally allocates the fixed maximum delay for all eight stages. Capacity ranges from one sample through 100 ms and must fit the current configuration. A failed call preserves the live state. |
configure(config) |
Transactionally validates and publishes stage count, each stage's delay/gain, and stereo width. It may be called before prepare() to select a configuration for a short capacity; after preparation, success resets history. Gain magnitude and stereo width are each either exactly zero or at least 0.02, keeping declared direct and routed feed-through above the tail-flush floor; gain magnitude is capped at 0.95 and width at 1. |
process_sample(...) / process_block(...) |
Processes wet-only stereo without allocation; blocks may be in place and partition arbitrarily. |
reset() |
Logically clears all histories in constant time without allocation. A non-finite input or internal overflow also clears state and emits silence for that sample. |
latency_samples() |
Reports the earliest observable response cached during prepare() or configure(). Bounded left/right impulse probes include route cancellation and delayed rerouting in mixed zero/non-zero-gain cascades. |
tail_samples() |
Returns -1 for a recursive allpass tail, a conservative finite bound for an all-zero-gain cascade, or zero for no active stages. |
The scalar allpasses have unit magnitude and the width matrix is orthonormal, so the complete feed-forward network preserves infinite-horizon stereo L2 energy apart from floating-point roundoff and the shared below-1e-15 tail flush. Width zero disables channel coupling; width one applies a 45-degree alternating rotation per stage. Left/right delay offsets are fixed and deterministic, so resets and separately prepared instances reproduce exactly.
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). |
set_ballistics(CompressorBallistics) |
Select how gain reduction is smoothed (branching default, or decoupled). |
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 constant (tau) |
release_ms |
100.0 | ms | Release time constant (tau) |
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).
Attack, release and the ballistics. The level detector is per sample
(instantaneous peak), and the gain reduction the static curve asks for is
smoothed in the dB domain by one of two forms (Giannoulis, Massberg and
Reiss, "Digital Dynamic Range Compressor Design", JAES 2012). attack_ms and
release_ms are one-pole time constants.
branching(default): a single one-pole that switches between the attack and release coefficients. A steady-level step moves from 10% to 90% of its gain change in 2.2x the setting either way (11 ms and 220 ms at 5 / 100 ms). On a tone it rides the waveform: attack measures about twice the knob at 1 kHz, the reduction settles short of the static curve, and the gain ripples at twice the tone frequency, which is harmonic distortion on low tones.decoupled(opt in withset_ballistics(CompressorBallistics::decoupled)): a peak hold that decays at the release time, then an attack one-pole. On tones it behaves like a step: attack follows the knob (2.2x), the reduction lands on the static curve, and the gain stays steady within each cycle. Its release is the hold and the attack pole in series, so a step releases in 2.2xrelease_msonly while attack is much shorter than release: 220 ms at 5 / 100 ms, but 155 ms at 30 / 60 ms and 168 ms at 50 / 50 ms (branching: 132 and 110 ms).
| -6 dBFS tone, 5 / 100 ms, -20 dB 4:1 hard knee | branching |
decoupled |
|---|---|---|
| 1 kHz attack, 10% to 90% (knob gives 11.0 ms) | 21.5 ms | 11.0 ms |
| Settled reduction (static curve -10.50 dB) | -9.75 dB | -10.49 dB |
| THD+N at 50 / 100 / 1000 Hz | -39.5 / -45.5 / -65.5 dB | -48.8 / -60.3 / -99.8 dB |
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.
AutoDuckedSend¶
AutoDuckedSend applies source- or sidechain-driven attenuation to an already
created wet/send signal. It is deliberately not a delay, reverb, dry/wet mixer,
or graph-routing policy: the dry path is never passed to this object.
signal::AutoDuckedSend send_ducker;
send_ducker.configure({
.threshold_db = -24.0f,
.range_db = 12.0f,
.attack_ms = 10.0f,
.release_ms = 250.0f,
.send_gain_db = -3.0f,
});
send_ducker.prepare(sample_rate);
// `wet_*` came from an effect; `source_*` is only the detector input.
auto ducked = send_ducker.process(wet_left, wet_right, source_left, source_right);
output_left = dry_left + ducked[0];
output_right = dry_right + ducked[1];
Above threshold, detector level maps to 1:1 send attenuation up to the
non-negative range_db; send_gain_db is an independent base gain. Peak-linked
stereo detection is the default and uses max(abs(L), abs(R)), so opposite
polarity cannot cancel or move the stereo image. Independent detection is
available through DynamicsStereoLink::independent.
configure() is transactional: malformed values reject the whole update and
preserve detector history. range_db = 0 with send_gain_db = 0, and explicit
bypass, return finite send samples exactly while detection remains current.
Prepared sample and block processing (including in-place send buffers) allocate
no memory. The processor has zero latency and zero tail because it stores only
detector/control history, never audio history.
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.
ClassicReverb¶
ClassicReverb (<pulp/signal/classic_reverb.hpp>) is the conventional tier:
eight lowpass-feedback combs per rail in parallel, then four allpasses in
series, all at the host rate. It takes the same fdn::Param controls and
fdn::Mode presets as FdnReverb and produces wet-only stereo, so it can stand
in wherever the FDN's cost is not wanted — directly, or through
FdnReverb::set_tier(fdn::Tier::classic).
signal::ClassicReverb reverb;
reverb.prepare(sample_rate, max_block_size);
reverb.set_parameter(signal::fdn::Param::decay, 1.5); // T60, seconds
reverb.process_block(in_left, in_right, wet_left, wet_right, num_samples);
Every comb's feedback gain follows the absorptive law, so decay is a real
T60 rather than a feedback amount. damp_lo, mod, shimmer, drive, bloom,
and tank_rate are accepted and ignored — the tier has none of that machinery.
See Tiers for the full control mapping.
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.
TransferCurve¶
TransferCurve maps an explicit input domain through up to 32 ordered control
points. Configuration is validated and published as one lock-free snapshot, so
the audio thread never observes a partly edited curve. Inputs outside the
domain clamp to the endpoint outputs, and non-finite samples recover to zero.
std::array<signal::TransferCurvePoint, 3> points{{
{-1.0f, -1.0f}, {0.0f, 0.1f}, {1.0f, 1.0f}
}};
signal::TransferCurve curve;
curve.publish_curve(points, -1.0f, 1.0f, -1.0f, 1.0f);
curve.process(buffer, num_samples);
Each point's optional curve_to_next uses the shared modulation-curve shapes;
two-field point initializers default to linear interpolation. The processor has
zero latency and no tail. It intentionally does not choose an anti-aliasing
policy: consumers applying nonlinear curves to audio own any required
oversampling and can compose the curve with OversamplerT.
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 minimum-phase polyphase IIR lane (the default), the linear-phase FIR lane, or the legacy Biquad lane. |
set_quality(Quality) |
Linear-phase lane: standard (96 dB prototype, flat to 80% of Nyquist) or pristine (140 dB prototype, flat to 90% of Nyquist). Polyphase lane: standard (114.6 dB) or pristine (153.2 dB), both from 0.515 Fs and flat to 0.485 Fs. No effect on the Biquad 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, Kind::polyphase_iir, cascades elliptic half-band allpass stages (see Oversampling filters and the real-input FFT for its rejection, passband and delay). Kind::fir_biquad 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. 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¶
Power-of-two FFT with in-place complex transforms and half-spectrum real transforms. Size must be a power of 2. Construction does all allocation; the transforms allocate nothing.
Float transforms run on vDSP on Apple and on muFFT everywhere else (SIMD-dispatched at run time on x86-64, portable C on other processors). Double precision, and float transforms under 16 points off Apple, use a built-in radix-2 transform. Every backend gives the same scaling, bin order and split layout, and the same accuracy to within a few dB of rounding (about -124 to -144 dB of the largest bin); test_fft_backend_accuracy.cpp holds each one to that contract.
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. Output is delayed by exactly one block: a block is transformed once it has filled, and its result is read out while the next one fills.
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 real-input FFT-multiply-IFFT cycle runs. An impulse response longer than the block spreads each block's tail over several later blocks; the pending overlap is accumulated across blocks, so the output is the full convolution, one block late.
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.
MultiChannelMeter¶
MultiChannelMeter (<pulp/signal/multi_channel_meter.hpp>) measures sample
peak, RMS, clips, stereo correlation, and channel-based BS.1770 loudness
(momentary, gated integrated, EBU short-term, loudness range) on the audio
thread, and publishes a MultiChannelMeterData snapshot about every 10 ms.
signal::MultiChannelMeter meter;
meter.prepare(sample_rate, 2); // everything
// or: meter.prepare(sample_rate, 2, nullptr, signal::MeterFeatures::levels);
meter.process(channels, 2, num_samples);
const auto& snapshot = meter.snapshot();
process() works a block at a time: peak, energy, and correlation come from
vector reductions, and the K-weighting runs channel pairs as one two-lane
recursion. A block holding a non-finite or out-of-range sample is measured
sample by sample under the same sanitizing rules, so the readings do not depend
on which path ran. Prepare with MeterFeatures::levels (or
MeterFeatures::correlation) when only peak/RMS bars are drawn: loudness is
most of the per-sample cost, and leaving it out makes the meter cost about as
much as a plain peak + RMS pass.
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.
Tempo-synced delay time¶
tempo_delay_samples() converts the canonical persisted BeatDivision
vocabulary—including dotted and triplet values—to an unrounded fractional
sample count. It uses the same 1–1000 BPM and maximum 768 kHz domains as the
compiled tempo map, but it does not create, copy, or query a tempo map. A beat
always means a quarter note; meter affects which division a product presents,
not its duration.
TempoDelayTime adds a fixed-state read-time transition for an existing delay
engine:
signal::TempoDelayTime sync;
sync.prepare(sample_rate, delay.max_delay());
sync.set_tempo(timebase::BeatDivision::EighthDotted, 120.0);
// The caller retains ownership of audio history and feedback routing.
delay.push(input);
const float wet = delay.read(static_cast<float>(sync.next()));
The first setting after prepare() is immediate. Subsequent settings move
linearly over exactly 64 samples by default; retargeting starts at the current
published value. reset() collapses an active move to its target. Invalid BPM,
sample rate, division, capacity, and nonfinite values reject transactionally
without changing the current move. render() produces the same sample sequence
for every host block partition and allocates no memory.
The transition is deterministic delay-time modulation, not an audio crossfade: large read-time moves can produce an intentional pitch sweep. A delay engine that requires a click-free jump should use these targets to drive its own dual-read-head crossfade; read heads and audio-history ownership stay outside this helper.
This is control state only: it has zero audio latency and zero audio tail.
conservative_delay_samples() lets an audio-history owner retain the longest
read time traversed by the current transition in its own tail calculation. Use
CompiledTempoMap separately when document tempo ramps must be resolved at
timeline positions; this helper intentionally does not duplicate that
authority.