Pulp 1.88.0
Cross-platform audio plugin framework
Loading...
Searching...
No Matches
convolver.hpp
Go to the documentation of this file.
1#pragma once
2
4#include <pulp/signal/fft.hpp>
6
7#include <algorithm>
8#include <cassert>
9#include <complex>
10#include <cstddef>
11#include <cstdint>
12#include <memory>
13#include <vector>
14
15namespace pulp::signal {
16
45template <typename SampleType = float>
47public:
49
55 void load_ir(const SampleType* ir, std::size_t ir_length, std::size_t block_size) {
56 state_ = detail::build_convolver_ir_state(ir, ir_length, block_size);
57 // Adopt the freshly-built state's spare as THE input history. Loading an
58 // IR outright (as opposed to swapping one in) deliberately starts the
59 // stream cold — this is a prepare()-time call, not a live change.
60 history_ = state_ ? std::move(state_->history) : nullptr;
61 }
62
73 // Gate the swap on retire-ring capacity FIRST so the audio
74 // thread never has to free the displaced IR inline if the
75 // ring is saturated. A single retired slot would let the audio
76 // thread deallocate when 2+ swaps happened between drain_old()
77 // calls. Refusing the swap when the ring is full is RT-safe:
78 // pending stays in the swapper for the next try_swap_ir attempt;
79 // in-flight state_ continues uninterrupted; worker thread catches
80 // up on its next drain tick.
81 if (state_ && !swapper.has_retire_capacity()) {
82 return false;
83 }
84
85 // A crossfade still running: don't start another swap and
86 // don't touch the in-flight fade-out — the pending IR waits for the next
87 // call. Human-paced IR swaps rarely overlap the ~ms fade window.
88 if (fading_ && !fade_mixer_.done())
89 return false;
90 // A completed fade-out can now be parked for the worker to free (never
91 // on the audio thread). Refuse the swap if the ring is full so we never
92 // free inline.
93 if (fading_) {
94 // Reserve TWO slots: one for this completed fade-out, one for the
95 // just-displaced IR should the incoming swap fall back to an instant
96 // retire below. Without this a near-full ring could accept the
97 // fade-out then fail the second retire and strand (leak) the
98 // displaced IR on the audio thread.
99 if (!swapper.has_retire_capacity(2)) return false;
100 (void)swapper.retire(fading_);
101 (void)fading_.release();
102 }
103
104 auto next = swapper.try_consume();
105 if (!next)
106 return false;
107
108 auto previous = std::move(state_);
109 state_ = std::move(next);
110
111 // The input delay line does NOT move with the swap. It belongs to the
112 // input stream, whose identity is unchanged by an IR change, so the
113 // convolver keeps the one it has and renders the incoming IR — and, under
114 // a crossfade, the outgoing one too — against that same history. Only the
115 // ring's SHAPE can force a change: a first load, a different block/FFT
116 // size, or an IR with more partitions than the ring has slots.
117 auto spare = std::move(state_->history);
118 const bool ring_serves_new =
119 history_ != nullptr &&
120 history_->block_size == state_->block_size &&
121 history_->fft_size == state_->fft_size &&
122 history_->spectra.size() >= state_->num_partitions;
123 if (!ring_serves_new) {
124 // Adopt the incoming state's spare ring — allocated off the audio
125 // thread precisely so this step never has to — carrying across
126 // whatever the old ring held. If the incoming state has no spare (a
127 // hand-built state that skipped it) the old ring is kept; process()
128 // detects the too-short ring and fails safe rather than aliasing it.
129 if (spare) {
130 if (history_) detail::migrate_input_history(*history_, *spare);
131 history_.swap(spare);
132 }
133 }
134
135 if (previous) {
136 // Crossfade path: keep the displaced IR rendering in parallel from the
137 // shared history and blend old->new. Only when a fade is configured AND
138 // the scratch fits both IRs' block size; otherwise fall back to the
139 // instant retire below. Require MATCHING block sizes: the fade-out
140 // render reads a full `previous->block_size` of `input`, but process()
141 // only guarantees num_samples == state_->block_size — a larger old
142 // block would read past the caller's input buffer. A mismatch falls
143 // back to instant.
144 const bool can_fade =
145 fade_samples_ > 0 &&
146 previous->block_size == state_->block_size &&
147 fade_scratch_.size() >= static_cast<std::size_t>(state_->block_size);
148 // Whichever ring lost the swap above is now rendered by nobody. Park
149 // it on the displaced state so it is reclaimed off the audio thread
150 // along with it — the audio thread must never free. The live state
151 // never holds a spare (it is taken above on every path that installs
152 // one), so this overwrites nothing and frees nothing.
153 assert(!previous->history);
154 previous->history = std::move(spare);
155 if (can_fade) {
156 fading_ = std::move(previous);
157 fade_mixer_.configure(fade_samples_, fade_curve_);
158 } else {
159 // Pre-checked capacity above; the false branch is defense-in-
160 // depth — leaks rather than freeing on RT.
161 const bool ok = swapper.retire(previous);
162 (void)ok;
163 (void)previous.release();
164 }
165 } else {
166 // `previous` is null only on a first load into an unloaded convolver,
167 // where `history_` was null too — so the adopt branch above left
168 // `spare` empty and nothing is freed on the audio thread here.
169 assert(!spare);
170 }
171 return true;
172 }
173
195 void set_crossfade(std::size_t samples, TransitionCurve curve = TransitionCurve::Smoothstep) {
196 fade_samples_ = samples;
197 fade_curve_ = curve;
198 if (state_) {
199 fade_scratch_.assign(static_cast<std::size_t>(state_->block_size),
200 SampleType{0.0f});
201 }
202 }
203
231 void process(const SampleType* input,
232 SampleType* output,
233 std::size_t num_samples) {
234 // No IR to convolve with — or no input history long enough to serve it,
235 // which only a hand-built state that skipped its `history` spare can
236 // produce. Either way the convolver is a wire rather than something that
237 // would read ring slots that do not exist.
238 if (!state_ || state_->ir_spectra.empty() || !history_ ||
239 history_->block_size != state_->block_size ||
240 history_->fft_size != state_->fft_size ||
241 history_->spectra.size() < state_->num_partitions) {
242 std::copy_n(input, num_samples, output);
243 return;
244 }
245 // Loaded, but the caller's block size does not match the IR partitioning.
246 if (static_cast<int>(num_samples) != state_->block_size) {
247 std::fill_n(output, num_samples, SampleType{0.0f});
248 ++block_size_violations_;
249 history_torn_ = true;
250 return;
251 }
252 // Recover from a previous violation before folding a valid block in.
253 if (history_torn_) reset();
254
255 // Fold this block into the shared input history ONCE — one forward FFT
256 // per block however many IRs render from it.
257 capture_input(*history_, *state_->fft, input);
258
259 render_ir(*state_, *history_, output);
260
261 // Crossfade the retiring IR: render it in parallel from the SAME history
262 // into scratch and blend old→new. RT-safe: scratch is pre-sized, no
263 // alloc; the fade-out IR is retired on the ring after the fade (or
264 // opportunistically once done), never freed on the audio thread.
265 if (fading_ && !fade_mixer_.done()) {
266 // Everything the parallel render needs. All three hold for any fade
267 // try_swap_ir() actually starts — it only begins one when the scratch
268 // fits and the block sizes match, and the shared ring is always at
269 // least as long as the outgoing IR needs.
270 const bool can_render =
271 fade_scratch_.size() >= num_samples &&
272 static_cast<std::size_t>(fading_->block_size) == num_samples &&
273 history_->spectra.size() >= fading_->num_partitions;
274 if (can_render) {
275 render_ir(*fading_, *history_, fade_scratch_.data());
276 const std::size_t base = fade_mixer_.position();
277 for (std::size_t i = 0; i < num_samples; ++i) {
278 SampleType old_gain, new_gain;
279 fade_mixer_.gains_at(base + i, old_gain, new_gain);
280 output[i] = output[i] * new_gain + fade_scratch_[i] * old_gain;
281 }
282 }
283 // Advance even when the render was skipped. A fade that cannot render
284 // must still COMPLETE: `fading_` is only retired once the mixer is
285 // done, and try_swap_ir() refuses every swap while a fade is running,
286 // so a fade that never finishes would wedge the convolver on the IR
287 // it happens to be holding.
288 fade_mixer_.advance(num_samples);
289 }
290 // When the fade completes the fade-out IR is simply held (not rendered
291 // anymore); it is parked on the swapper's retire ring by the next
292 // try_swap_ir() — never freed on the audio thread here.
293
294 // Step the shared ring once, after every IR has read this block.
295 advance_input(*history_);
296 }
297
303 void reset() {
304 clear_history(history_.get());
305 history_torn_ = false;
306 block_size_violations_ = 0;
307 }
308
313 std::size_t latency() const { return 0; }
314
322 std::size_t block_size() const {
323 return state_ ? static_cast<std::size_t>(state_->block_size) : 0;
324 }
325
333 std::uint64_t block_size_violations() const { return block_size_violations_; }
334
335 std::size_t num_partitions() const {
336 return state_ ? state_->num_partitions : 0;
337 }
338
339 bool is_loaded() const {
340 return state_ && !state_->ir_spectra.empty();
341 }
342
343 private:
344 // Fold one input block into the shared history: copy it into the upper
345 // half of the real overlap window and take its real forward transform into
346 // the current ring slot (DC..Nyquist only; every spectrum here is the
347 // transform of real data, so the upper half is never needed). Called ONCE
348 // per processed block, before any IR renders.
349 static void capture_input(ConvolverInputHistoryT<SampleType>& h,
350 FftT<SampleType>& fft,
351 const SampleType* input) {
352 SampleType* window = overlap_samples(h);
353 std::copy_n(input, h.block_size, window + h.block_size);
354 // The transform is a property of the FFT size, which every IR rendering
355 // against this history shares, so any loaded state's `fft` will do.
356 fft.forward_real_half(window, h.spectra[h.write_pos].data());
357 }
358
359 // Slide the overlap window down and step the ring cursor. Called ONCE per
360 // processed block, after every IR has read this block — a crossfade renders
361 // twice from one history, so stepping per render would double-advance it.
362 static void advance_input(ConvolverInputHistoryT<SampleType>& h) {
363 SampleType* window = overlap_samples(h);
364 std::copy_n(window + h.block_size, h.block_size, window);
365 h.write_pos = (h.write_pos + 1) % h.spectra.size();
366 }
367
368 // The overlap window as `fft_size` real samples, packed into the leading
369 // scalars of the history's complex `overlap` storage.
370 // (std::complex<T> is layout-compatible with T[2], [complex.numbers].)
371 static SampleType* overlap_samples(ConvolverInputHistoryT<SampleType>& h) {
372 return reinterpret_cast<SampleType*>(h.overlap.data());
373 }
374
375 // Multiply-accumulate one IR's partitions against the shared input history
376 // and write the block's time-domain result. Shared by the live path (state_)
377 // and the parallel fade-out render (fading_). Reads the history; never writes
378 // or advances it. Caller guarantees `s` has a loaded IR and that the ring is
379 // at least `s.num_partitions` long.
380 static void render_ir(ConvolverIrStateT<SampleType>& s,
381 const ConvolverInputHistoryT<SampleType>& h,
382 SampleType* output) {
383 // Input and IR are transforms of real data, so every spectrum is
384 // Hermitian and only DC..Nyquist (N/2+1 bins) is independent. Only those
385 // bins are multiply-accumulated, and the real inverse transform below
386 // reads only those bins.
387 const int half = s.fft_size / 2; // Nyquist bin; MAC bins [0, half]
388 const std::size_t capacity = h.spectra.size();
389 std::complex<SampleType>* accum = s.accum.data();
390 const auto spectrum = [&](std::size_t p) {
391 // Partition p convolves with the input block of age p. `write_pos`
392 // holds the block being processed, so age p is p slots back.
393 return h.spectra[(h.write_pos + capacity - p) % capacity].data();
394 };
395 // Complex MACs as explicit float ops: the finite-value result of
396 // std::complex::operator* without its libm NaN/Inf-recovery branch, so
397 // the loops vectorize. Matches operator* for the finite spectra a
398 // convolver sees (differences are sub-ulp). Partition 0 writes rather
399 // than accumulates, so the accumulator never needs clearing; the rest
400 // go four at a time so each pass loads and stores the accumulator once
401 // for four products.
402 {
403 const std::complex<SampleType>* in = spectrum(0);
404 const std::complex<SampleType>* ir = s.ir_spectra[0].data();
405 for (int i = 0; i <= half; ++i)
406 accum[i] = cmul(in[i], ir[i]);
407 }
408 std::size_t p = 1;
409 for (; p + 4 <= s.num_partitions; p += 4) {
410 const std::complex<SampleType>* in0 = spectrum(p);
411 const std::complex<SampleType>* in1 = spectrum(p + 1);
412 const std::complex<SampleType>* in2 = spectrum(p + 2);
413 const std::complex<SampleType>* in3 = spectrum(p + 3);
414 const std::complex<SampleType>* ir0 = s.ir_spectra[p].data();
415 const std::complex<SampleType>* ir1 = s.ir_spectra[p + 1].data();
416 const std::complex<SampleType>* ir2 = s.ir_spectra[p + 2].data();
417 const std::complex<SampleType>* ir3 = s.ir_spectra[p + 3].data();
418 for (int i = 0; i <= half; ++i)
419 accum[i] += (cmul(in0[i], ir0[i]) + cmul(in1[i], ir1[i])) +
420 (cmul(in2[i], ir2[i]) + cmul(in3[i], ir3[i]));
421 }
422 for (; p < s.num_partitions; ++p) {
423 const std::complex<SampleType>* in = spectrum(p);
424 const std::complex<SampleType>* ir = s.ir_spectra[p].data();
425 for (int i = 0; i <= half; ++i)
426 accum[i] += cmul(in[i], ir[i]);
427 }
428
429 // Real inverse in the accumulator's own storage, 1/N folded in; overlap-
430 // save keeps the second half of the window.
431 // std::complex<T> is layout-compatible with T[2] ([complex.numbers]),
432 // so the accumulator's N/2+1 complex slots hold the N real samples.
433 auto* time = reinterpret_cast<SampleType*>(accum);
434 s.fft->inverse_real_half(accum, time);
435 std::copy_n(time + s.block_size, s.block_size, output);
436 }
437
438 static std::complex<SampleType> cmul(std::complex<SampleType> a,
439 std::complex<SampleType> b) noexcept {
440 return {a.real() * b.real() - a.imag() * b.imag(),
441 a.real() * b.imag() + a.imag() * b.real()};
442 }
443
444 // Zero the shared input history so the next block starts a new stream.
445 // Null-tolerant so reset() can call it on an unloaded convolver.
446 static void clear_history(ConvolverInputHistoryT<SampleType>* h) {
447 if (!h) return;
448 constexpr std::complex<SampleType> kZero{SampleType{0.0f}, SampleType{0.0f}};
449 h->spectra.fill_zero();
450 std::fill(h->overlap.begin(), h->overlap.end(), kZero);
451 h->write_pos = 0;
452 }
453
454 std::unique_ptr<ConvolverIrStateT<SampleType>> state_;
455 // The input delay line, shared by every IR this convolver renders. Outlives
456 // any one IR: a swap replaces `state_`, never this. Its ring capacity is the
457 // high-water mark of partition counts seen since the last `load_ir()`, since
458 // growing it on the audio thread means adopting an incoming state's spare and
459 // shrinking it would mean throwing history away for nothing.
460 std::unique_ptr<ConvolverInputHistoryT<SampleType>> history_;
461 // Set when process() is handed a block whose size does not match the loaded
462 // IR partitioning. The next valid block resets before folding itself in.
463 bool history_torn_ = false;
464 std::uint64_t block_size_violations_ = 0;
465
466 // ── Crossfade state (opt-in via set_crossfade) ────────────────────────────
467 std::unique_ptr<ConvolverIrStateT<SampleType>> fading_; // IR fading out (parallel render)
468 signal::TransitionMixerT<SampleType> fade_mixer_; // shared click-free blend
469 std::size_t fade_samples_ = 0; // configured fade length (0 = instant)
471 std::vector<SampleType> fade_scratch_; // parallel-render output, pre-sized off-RT
472};
473
476
477} // namespace pulp::signal
Lock-free background-IR shuttle for PartitionedConvolver.
Definition convolver_messages.hpp:314
std::unique_ptr< ConvolverIrStateT< SampleType > > try_consume()
Audio-thread-callable: atomically claim the most recently staged IR, if any.
Definition convolver_messages.hpp:345
bool retire(std::unique_ptr< ConvolverIrStateT< SampleType > > &displaced)
Audio-thread-callable: park the IR that the audio thread is displacing so a non-RT thread can free it...
Definition convolver_messages.hpp:360
bool has_retire_capacity() const
True if there's room in the retire ring for one more displaced IR.
Definition convolver_messages.hpp:368
Definition fft.hpp:176
void forward_real_half(const SampleType *input, std::complex< SampleType > *output, SampleType scale) const
Real-input forward transform.
Definition fft.hpp:433
Uniform partitioned convolution engine.
Definition convolver.hpp:46
bool try_swap_ir(ConvolverIrSwapperT< SampleType > &swapper)
Try to consume the most recently staged IR from a swapper.
Definition convolver.hpp:72
std::size_t num_partitions() const
Definition convolver.hpp:335
void load_ir(const SampleType *ir, std::size_t ir_length, std::size_t block_size)
Load an impulse response.
Definition convolver.hpp:55
bool is_loaded() const
Definition convolver.hpp:339
void reset()
Drop the convolver's overlap-save input history so the next block starts from silence rather than con...
Definition convolver.hpp:303
void process(const SampleType *input, SampleType *output, std::size_t num_samples)
Process a block of audio.
Definition convolver.hpp:231
std::uint64_t block_size_violations() const
How many times process() was called on a loaded convolver with num_samples != block_size().
Definition convolver.hpp:333
std::size_t latency() const
Returns the algorithmic latency in samples.
Definition convolver.hpp:313
void set_crossfade(std::size_t samples, TransitionCurve curve=TransitionCurve::Smoothstep)
Enable a click-free crossfade on IR swaps (opt-in — default 0 = instantaneous swap).
Definition convolver.hpp:195
std::size_t block_size() const
Definition convolver.hpp:322
@ b
Definition pitch.hpp:31
void migrate_input_history(ConvolverInputHistoryT< SampleType > &from, ConvolverInputHistoryT< SampleType > &to)
Migrate the recorded input history from from into to, age-aligned.
Definition convolver_messages.hpp:265
std::unique_ptr< ConvolverIrStateT< SampleType > > build_convolver_ir_state(const SampleType *ir, std::size_t ir_length, std::size_t block_size)
Build a ConvolverIrState from a raw IR.
Definition convolver_messages.hpp:199
@ half
Definition units.hpp:290
Definition additive_bank.hpp:218
PartitionedConvolverT< double > PartitionedConvolver64
Definition convolver.hpp:475
PartitionedConvolverT< float > PartitionedConvolver
Definition convolver.hpp:474
@ a
Definition formant_filter_bank.hpp:20
@ i
Definition formant_filter_bank.hpp:20
@ ok
Definition breakpoint_envelope.hpp:36
TransitionCurve
Definition transition_mixer.hpp:34
@ Smoothstep
Definition transition_mixer.hpp:34
@ spectrum
Definition design_ir.hpp:265
@ s
Definition input_events.hpp:41
@ h
Definition input_events.hpp:39
@ p
Definition input_events.hpp:40
The convolver's input-side overlap-save history.
Definition convolver_messages.hpp:116
Shared click-free crossfade primitive.