convolution.h
Convolution of any length, at zero latency, with impulse responses swapped while the audio runs.
#include <sonore/convolution.h>- 3types
- 1other declaration
- Guides: Getting started, MIDI and MPE
On this page
Design notes
Convolver in fft.h is the small one: sized at compile time (16384 samples with its defaults), one block of latency, and an impulse that is loaded on the audio thread or handed over by swapping whole convolvers. That is the right tool for a cabinet. A convolution REVERB needs what it cannot do:
- an impulse of any length, sized when it is loaded -- a ten-second hall is 480000 samples at 48 kHz;
- no latency at all, because a reverb sits on a send and a send that is late against the dry signal smears every transient it touches;
- a new impulse while the music plays: built somewhere other than the audio thread, handed over without a lock, and CROSSFADED in rather than cut over, which clicks.
How, in one paragraph each.
ZERO LATENCY. The first headBlock samples of the impulse are a direct- form FIR, computed sample by sample, so the output for a sample exists the moment the sample does. Everything after them is convolved by FFT in blocks, and a block is only ever needed AFTER it has been filled: the segment of the impulse that a block size P serves starts at least P samples in, so its contribution is due no earlier than the block's end. With zeroLatency off the FIR is dropped and the output is one head block late, which costs a little less.
ANY LENGTH, CHEAPLY. Non-uniform partitioning (Gardner, "Efficient convolution without input-output delay", JAES 1995): two partitions at the head block size, two at twice it, two at four times, ... up to maxBlock, and the whole rest of the impulse at maxBlock. Short blocks where latency matters, long blocks -- cheap per sample -- for the tail. Each size is uniformly partitioned (a frequency-domain delay line, as Convolver is), all of them on the real-signal transforms of DynamicFft.
NO SPIKES. A long block's work used to arrive all at once: at the block's end, a multiply-accumulate over EVERY partition of the tail, which for a ten-second impulse is the whole impulse's spectrum. Here that sum is spread over the block it is for: the partitions that only need OLDER input are accumulated a slice at a time at each head-block boundary while the block fills, and its end has only the newest partition and the two transforms left.
SWAPPING. An Engine is everything built from one impulse; it is made on the calling thread (or a background one, loadImpulseResponseAsync), handed to the audio thread through one atomic pointer, and crossfaded in over crossfadeSeconds. The engine it replaces goes back through a lock-free queue to be freed off the audio thread. Nothing on the audio thread allocates, locks, or frees.
What it does NOT do, stated: a new engine starts with no input history, so for the length of the crossfade the new reverb's tail is building up while the old one's fades -- the early part of the new response is there at once, its late tail arrives over the impulse's own length.
Types 3
How a Convolution partitions and swaps. Read when an impulse is LOADED (and by prepare()), so a change applies from the next load.
Everything built from one impulse response: a convolution per channel and the transforms they share. Immutable once built except for the audio state, which only the audio thread touches after it is handed over.
Convolution reverb and cabinet, of any length, at zero latency, with impulse responses that change while the audio runs.
// prepare(): the rate the impulses are resampled to, and the scratch. conv.prepare(spec); // message thread (or a background one): build, then hand over. ImpulseResponse ir; ir.loadFromFile(path, spec.sampleRate); conv.loadImpulseResponse(ir); // audio thread: conv.process(block);
Until an impulse is loaded it passes the signal through, at its latency. A load builds on the calling thread and publishes wait-free; the audio thread picks the new engine up at its next block and crossfades to it.