rt_fft_convolver/engine/partition.rs
1//! Uniform partitioned convolution for the IR **tail**.
2//!
3//! # Why partition the IR?
4//!
5//! A direct time-domain convolution costs O(N·L) per block, where L is the IR
6//! length. For a 4-second guitar cabinet IR at 48 kHz (L ≈ 192 000 samples)
7//! that is completely impractical in real time. The FFT-based overlap-save
8//! method reduces the per-block cost to O(N · log N · P) where P = ⌈L/N⌉ is
9//! the number of partitions — a dramatic saving once N ≪ L.
10//!
11//! # Overlap-Save algorithm
12//!
13//! The IR tail (everything after the zero-latency head handled by
14//! [`DirectConvolver`]) is split into equal **partitions** of `block_size`
15//! samples. Each partition is zero-padded to `2 × block_size` and
16//! pre-transformed via a real-to-complex FFT, giving a frequency-domain
17//! representation of `block_size + 1` unique complex bins stored in
18//! [`ir_spectra`] (the remaining bins of a full 2N-point spectrum are
19//! conjugate-symmetric and are omitted, halving spectrum memory).
20//!
21//! On every audio callback (one block of `block_size` samples):
22//!
23//! ```text
24//! ┌─────────────────────────────────────────────────────────────────────┐
25//! │ 1. Slide the 2N overlap-save window: │
26//! │ input_buf = [prev_block | new_block] │
27//! │ │
28//! │ 2. FFT(input_buf) → current input spectrum X │
29//! │ │
30//! │ 3. Store X in the frequency-domain delay line (FDL) at fdl_pos │
31//! │ │
32//! │ 4. acc = Σ_{k=0}^{P-1} FDL[(pos−k) mod P] ⊙ IR_spectra[k] │
33//! │ (complex multiply-accumulate over all partitions) │
34//! │ │
35//! │ 5. IFFT(acc) → 2N time-domain samples │
36//! │ │
37//! │ 6. Discard first N samples (circular wrap-around artifact). │
38//! │ Add second N samples to output. │
39//! └─────────────────────────────────────────────────────────────────────┘
40//! ```
41//!
42//! # Latency
43//!
44//! This engine produces perfectly causal output: `output[t]` uses `input[t]`
45//! through `input[t - L + 1]`, with zero algorithmic latency added beyond
46//! what the IR itself dictates. Pair it with a [`DirectConvolver`] for the
47//! head partition (which has zero latency) to handle the full IR.
48//!
49//! [`DirectConvolver`]: crate::dsp::direct::DirectConvolver
50
51use rustfft::num_complex::Complex;
52
53use crate::dsp::FftHandler;
54use crate::engine::fdl::{FreqDomainDelayLine, SplitSpectrum};
55
56/// Uniform partitioned convolution engine for the IR tail.
57///
58/// Split the IR tail into equal partitions of [`block_size`] samples, then
59/// process each audio block using the overlap-save algorithm in the frequency
60/// domain.
61///
62/// Construct once on a non-real-time thread, then call [`process`] from the
63/// audio render thread for each block.
64///
65/// [`block_size`]: UniformPartitionEngine::block_size
66/// [`process`]: UniformPartitionEngine::process
67pub struct UniformPartitionEngine {
68 /// Audio block size N (= partition size).
69 block_size: usize,
70
71 /// Pre-computed 2N-point real-input FFT of each IR tail partition,
72 /// stored as its `N + 1` unique complex bins (the rest are
73 /// conjugate-symmetric and omitted) in split re/im layout so the
74 /// complex-MAC kernel loads plain contiguous vectors. `ir_spectra[k]`
75 /// is the spectrum of IR samples `k*N .. (k+1)*N` (zero-padded to 2N in
76 /// the time domain before the transform).
77 ir_spectra: Vec<SplitSpectrum>,
78
79 /// Frequency-domain delay line: circular buffer of the P most recent
80 /// input spectra. Updated once per block via [`FreqDomainDelayLine::push`].
81 fdl: FreqDomainDelayLine,
82
83 /// Overlap-save buffer of length 2N (time-domain).
84 /// `input_buf[0..N]` = previous block (the "overlap").
85 /// `input_buf[N..2N]` = current block (the "new" data).
86 input_buf: Vec<f32>,
87
88 /// Interleaved `[Complex<f32>]` buffer (`N + 1` bins) bridging the FFT
89 /// and the split-layout MAC: it receives the forward-FFT result (pushed
90 /// into the FDL) and, after [`SplitSpectrum::write_interleaved`], feeds
91 /// the inverse FFT.
92 acc_buf: Vec<Complex<f32>>,
93
94 /// Split re/im accumulator for the frequency-domain multiply-accumulate
95 /// step. Zeroed each block, then filled by [`FreqDomainDelayLine::accumulate`].
96 acc_split: SplitSpectrum,
97
98 /// Time-domain IFFT output buffer of length 2N.
99 /// The valid output lives in `out_buf[N..2N]`.
100 out_buf: Vec<f32>,
101
102 /// Pre-planned real-to-complex / complex-to-real 2N-point transforms.
103 fft: FftHandler,
104}
105
106impl UniformPartitionEngine {
107 /// Create an engine loaded with the given IR **tail** coefficients.
108 ///
109 /// `ir_tail` is the portion of the IR that comes *after* the head handled
110 /// by a [`DirectConvolver`] — i.e. `ir[head_len..]`. Pass `&[]` to
111 /// create a no-op (silence) engine.
112 ///
113 /// `block_size` must equal the number of samples per audio render callback
114 /// and must be > 0.
115 ///
116 /// **Allocates** — call from a non-real-time thread.
117 ///
118 /// # Panics
119 ///
120 /// Panics if `block_size == 0`.
121 ///
122 /// [`DirectConvolver`]: crate::dsp::direct::DirectConvolver
123 pub fn new(ir_tail: &[f32], block_size: usize) -> Self {
124 assert!(block_size > 0, "block_size must be > 0");
125
126 let fft_size = 2 * block_size;
127 // Real-input FFT stores only the N + 1 unique bins.
128 let spectrum_size = block_size + 1;
129
130 let num_partitions = if ir_tail.is_empty() {
131 0
132 } else {
133 ir_tail.len().div_ceil(block_size)
134 };
135
136 let mut fft = FftHandler::new(fft_size);
137
138 // ── Pre-transform each IR partition ───────────────────────────────
139 // Each partition is zero-padded from block_size to fft_size before
140 // the FFT so that the circular convolution matches linear convolution
141 // in the valid (second-half) output region.
142 let ir_spectra: Vec<SplitSpectrum> = (0..num_partitions)
143 .map(|k| {
144 let start = k * block_size;
145 let end = (start + block_size).min(ir_tail.len());
146
147 let mut padded = vec![0.0f32; fft_size];
148 padded[..end - start].copy_from_slice(&ir_tail[start..end]);
149
150 let mut spectrum = vec![Complex::default(); spectrum_size];
151 fft.forward(&padded, &mut spectrum);
152 // Store in split re/im layout for the complex-MAC kernel.
153 SplitSpectrum::from_complex(&spectrum)
154 })
155 .collect();
156
157 // FDL needs at least one slot; it is never accessed when num_partitions
158 // == 0 because process() returns early in that case.
159 let fdl = FreqDomainDelayLine::new(num_partitions.max(1), spectrum_size);
160
161 Self {
162 block_size,
163 ir_spectra,
164 fdl,
165 input_buf: vec![0.0f32; fft_size],
166 acc_buf: vec![Complex::default(); spectrum_size],
167 acc_split: SplitSpectrum::zeros(spectrum_size),
168 out_buf: vec![0.0f32; fft_size],
169 fft,
170 }
171 }
172
173 /// Number of IR tail partitions.
174 #[inline]
175 pub fn num_partitions(&self) -> usize {
176 self.ir_spectra.len()
177 }
178
179 /// Audio block size this engine was created with.
180 #[inline]
181 pub fn block_size(&self) -> usize {
182 self.block_size
183 }
184
185 /// Clear all delay lines and input history **without reallocating**.
186 ///
187 /// Call between song sections or preset changes to prevent clicks from
188 /// stale tail energy.
189 ///
190 /// **Real-time safe** — no allocation.
191 pub fn reset(&mut self) {
192 self.input_buf.fill(0.0);
193 self.fdl.reset();
194 self.acc_buf.fill(Complex::default());
195 self.acc_split.fill_zero();
196 self.out_buf.fill(0.0);
197 }
198
199 /// Replace the IR tail and reset all internal state.
200 ///
201 /// `block_size` is preserved from construction.
202 ///
203 /// **Allocates** — call from a non-real-time thread.
204 pub fn load_ir(&mut self, ir_tail: &[f32]) {
205 *self = Self::new(ir_tail, self.block_size);
206 }
207
208 /// Process one block of `block_size` input samples, **adding** the
209 /// convolved tail into `output`.
210 ///
211 /// `output` is *accumulated into*, not overwritten. The typical usage is:
212 ///
213 /// ```text
214 /// output.fill(0.0);
215 /// direct.process(input, output); // zero-latency head
216 /// tail_engine.process(input, output); // add FFT tail
217 /// ```
218 ///
219 /// Both slices must have length equal to [`Self::block_size()`].
220 ///
221 /// If the IR tail is empty this is a no-op (output is not touched).
222 ///
223 /// **Real-time safe** — no allocation, no locking, no panics in release
224 /// builds.
225 pub fn process(&mut self, input: &[f32], output: &mut [f32]) {
226 debug_assert_eq!(
227 input.len(),
228 self.block_size,
229 "process: input length must equal block_size"
230 );
231 debug_assert_eq!(
232 output.len(),
233 self.block_size,
234 "process: output length must equal block_size"
235 );
236
237 if self.ir_spectra.is_empty() {
238 return;
239 }
240
241 let n = self.block_size;
242
243 // ── Step 1: slide the overlap-save window ─────────────────────────
244 // First half ← old second half (previous block).
245 // Second half ← new input block.
246 self.input_buf.copy_within(n.., 0);
247 self.input_buf[n..].copy_from_slice(input);
248
249 // ── Step 2: FFT the 2N window into acc_buf ────────────────────────
250 self.fft.forward(&self.input_buf, &mut self.acc_buf);
251
252 // ── Step 3: push current spectrum into the FDL ────────────────────
253 self.fdl.push(&self.acc_buf);
254
255 // ── Step 4: frequency-domain multiply-accumulate ──────────────────
256 // acc = Σ_{k=0}^{P-1} FDL[k] ⊙ IR[k]
257 // (FDL slot 0 = most recently pushed; managed by FreqDomainDelayLine)
258 // The MAC runs in split re/im layout, then we re-interleave once into
259 // acc_buf for the complex-to-real inverse transform.
260 self.acc_split.fill_zero();
261 self.fdl.accumulate(&self.ir_spectra, &mut self.acc_split);
262 self.acc_split.write_interleaved(&mut self.acc_buf);
263
264 // ── Step 5: IFFT the accumulator ──────────────────────────────────
265 self.fft.inverse(&mut self.acc_buf, &mut self.out_buf);
266
267 // ── Step 6: accumulate the valid second half into output ──────────
268 // The first N samples are the circular wrap-around artifact; the
269 // second N samples are the correct linear-convolution result.
270 for (y, &x) in output.iter_mut().zip(self.out_buf[n..].iter()) {
271 *y += x;
272 }
273 }
274}
275
276// ─── Tests ────────────────────────────────────────────────────────────────────
277
278#[cfg(test)]
279mod tests {
280 use super::*;
281
282 const EPSILON: f32 = 1e-5;
283
284 // ── Helper ────────────────────────────────────────────────────────────────
285
286 /// Feed `blocks` of `block_size` samples through a fresh engine and
287 /// collect all output samples into one flat Vec.
288 fn run_blocks(ir_tail: &[f32], block_size: usize, blocks: &[&[f32]]) -> Vec<f32> {
289 let mut engine = UniformPartitionEngine::new(ir_tail, block_size);
290 let mut out = Vec::new();
291 for &block in blocks {
292 let mut buf = vec![0.0f32; block_size];
293 engine.process(block, &mut buf);
294 out.extend_from_slice(&buf);
295 }
296 out
297 }
298
299 // ── Structural ────────────────────────────────────────────────────────────
300
301 /// An empty IR tail must report 0 partitions.
302 #[test]
303 fn empty_ir_has_zero_partitions() {
304 let e = UniformPartitionEngine::new(&[], 4);
305 assert_eq!(e.num_partitions(), 0);
306 }
307
308 /// An IR of 7 samples with block_size=4 needs ⌈7/4⌉ = 2 partitions.
309 #[test]
310 fn partition_count_rounds_up() {
311 let e = UniformPartitionEngine::new(&[1.0f32; 7], 4);
312 assert_eq!(e.num_partitions(), 2);
313 }
314
315 // ── Silence / no-op ───────────────────────────────────────────────────────
316
317 /// Empty IR tail must not touch the output buffer.
318 #[test]
319 fn empty_ir_tail_leaves_output_unchanged() {
320 let mut engine = UniformPartitionEngine::new(&[], 4);
321 let input = [1.0f32, 2.0, 3.0, 4.0];
322 let mut output = [99.0f32; 4]; // non-zero sentinel
323 engine.process(&input, &mut output);
324 // process() must be a no-op — output unchanged
325 assert_eq!(output, [99.0f32; 4]);
326 }
327
328 // ── Single-partition correctness ──────────────────────────────────────────
329
330 /// Unit-impulse tail `[1, 0, 0, 0]` is an identity: output == input.
331 #[test]
332 fn unit_impulse_tail_is_identity() {
333 let block_size = 4;
334 let ir = [1.0f32, 0.0, 0.0, 0.0];
335 let input = [1.0f32, 2.0, 3.0, 4.0];
336 let out = run_blocks(&ir, block_size, &[&input]);
337 for (x, y) in input.iter().zip(out.iter()) {
338 assert!((x - y).abs() < EPSILON, "expected {x}, got {y}");
339 }
340 }
341
342 /// IR tail `[0, 1, 0, 0]` is a one-sample delay within the block.
343 /// output[0] = 0 (history empty), output[i] = input[i-1].
344 #[test]
345 fn one_sample_delay_tail() {
346 let block_size = 4;
347 let ir = [0.0f32, 1.0, 0.0, 0.0];
348 let input = [1.0f32, 2.0, 3.0, 4.0];
349 let expected = [0.0f32, 1.0, 2.0, 3.0];
350 let out = run_blocks(&ir, block_size, &[&input]);
351 for (e, y) in expected.iter().zip(out.iter()) {
352 assert!((e - y).abs() < EPSILON, "expected {e}, got {y}");
353 }
354 }
355
356 // ── Multi-partition correctness ───────────────────────────────────────────
357
358 /// IR `[1, 0, 0, 1]` with block_size=2 spans two partitions.
359 ///
360 /// y[n] = x[n] + x[n-3]. With impulse at x[0]:
361 /// block 1 → [y[0], y[1]] = [1, 0]
362 /// block 2 → [y[2], y[3]] = [0, 1]
363 #[test]
364 fn multi_partition_spans_two_blocks() {
365 let block_size = 2;
366 let ir = [1.0f32, 0.0, 0.0, 1.0];
367 let b1 = [1.0f32, 0.0];
368 let b2 = [0.0f32, 0.0];
369 let out = run_blocks(&ir, block_size, &[&b1, &b2]);
370 let expected = [1.0f32, 0.0, 0.0, 1.0];
371 for (e, y) in expected.iter().zip(out.iter()) {
372 assert!((e - y).abs() < EPSILON, "expected {e}, got {y}");
373 }
374 }
375
376 /// IR length is not a multiple of block_size — the final partition must be
377 /// zero-padded correctly so that its "missing" taps contribute 0.
378 ///
379 /// IR = [1, 2, 3, 4, 5] with block_size=2 → 3 partitions:
380 /// partition 0 = [1, 2]
381 /// partition 1 = [3, 4]
382 /// partition 2 = [5, 0] ← last tap zero-padded
383 ///
384 /// Feed a single impulse in block 1 and read the response across blocks.
385 /// Expected impulse response: [1, 2, 3, 4, 5, 0, ...].
386 #[test]
387 fn non_multiple_ir_length_is_zero_padded() {
388 let block_size = 2;
389 let ir = [1.0f32, 2.0, 3.0, 4.0, 5.0];
390 // Feed impulse in block 1, silence in subsequent blocks to read the tail.
391 let blocks: Vec<&[f32]> = vec![&[1.0, 0.0], &[0.0, 0.0], &[0.0, 0.0]];
392 let out = run_blocks(&ir, block_size, &blocks);
393 let expected = [1.0f32, 2.0, 3.0, 4.0, 5.0, 0.0];
394 for (i, (e, y)) in expected.iter().zip(out.iter()).enumerate() {
395 assert!((e - y).abs() < EPSILON, "y[{i}]: expected {e}, got {y}");
396 }
397 }
398
399 /// IR length that leaves the last partition mostly empty (1 tap out of 4).
400 /// Verifies the padded region contributes zero regardless of block size.
401 #[test]
402 fn single_tap_in_last_partition() {
403 let block_size = 4;
404 // 5 taps → 2 partitions: [1,10,100,1000] and [10000,0,0,0].
405 let ir = [1.0f32, 10.0, 100.0, 1000.0, 10000.0];
406 let blocks: Vec<&[f32]> = vec![
407 &[1.0, 0.0, 0.0, 0.0],
408 &[0.0, 0.0, 0.0, 0.0],
409 &[0.0, 0.0, 0.0, 0.0],
410 ];
411 let out = run_blocks(&ir, block_size, &blocks);
412 let expected = [
413 1.0f32, 10.0, 100.0, 1000.0, // block 1
414 10000.0, 0.0, 0.0, 0.0, // block 2 (only tap 4 fires)
415 0.0, 0.0, 0.0, 0.0, // block 3 (silence)
416 ];
417 // Tolerance scales with the peak IR magnitude: f32 FFT roundtrip has
418 // ~1e-6 relative precision, so absolute error can approach
419 // peak * 1e-4 on zero-valued output samples due to cancellation.
420 let peak = ir.iter().fold(0.0f32, |m, &x| m.max(x.abs()));
421 let tol = peak * 1e-4;
422 for (i, (e, y)) in expected.iter().zip(out.iter()).enumerate() {
423 let err = (e - y).abs();
424 assert!(err < tol, "y[{i}]: expected {e}, got {y} (abs err {err})");
425 }
426 }
427
428 /// Known two-tap convolution: input=[1,2,3,4], IR=[1,2], block_size=4.
429 /// y[n] = 1*x[n] + 2*x[n-1].
430 /// Expected: [1*1+2*0, 1*2+2*1, 1*3+2*2, 1*4+2*3] = [1, 4, 7, 10].
431 #[test]
432 fn known_two_tap_convolution() {
433 let block_size = 4;
434 let ir = [1.0f32, 2.0, 0.0, 0.0];
435 let input = [1.0f32, 2.0, 3.0, 4.0];
436 let expected = [1.0f32, 4.0, 7.0, 10.0];
437 let out = run_blocks(&ir, block_size, &[&input]);
438 for (e, y) in expected.iter().zip(out.iter()) {
439 assert!((e - y).abs() < EPSILON, "expected {e}, got {y}");
440 }
441 }
442
443 // ── State management ──────────────────────────────────────────────────────
444
445 /// State persists across blocks: a tail fed in block 1 appears in block 2.
446 #[test]
447 fn state_persists_across_blocks() {
448 let block_size = 4;
449 // IR spans 2 partitions: h[0]=1, h[7]=1 → y[n] = x[n] + x[n-7].
450 let ir = [1.0f32, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0];
451 // Block 1: impulse at x[0].
452 let b1 = [1.0f32, 0.0, 0.0, 0.0];
453 // Block 2: silence — tail from h[7] of the previous impulse appears here.
454 let b2 = [0.0f32; 4];
455 let out = run_blocks(&ir, block_size, &[&b1, &b2]);
456 // y[0] = 1, y[7] = 1, all others = 0.
457 assert!((out[0] - 1.0).abs() < EPSILON, "y[0]={}", out[0]);
458 assert!((out[7] - 1.0).abs() < EPSILON, "y[7]={}", out[7]);
459 for (i, val) in out.iter().enumerate().take(7).skip(1) {
460 assert!(val.abs() < EPSILON, "y[{i}]={val}");
461 }
462 }
463
464 /// `reset()` clears state — result matches a fresh engine.
465 #[test]
466 fn reset_gives_same_result_as_fresh_engine() {
467 let block_size = 4;
468 let ir = [1.0f32, 0.5, 0.0, 0.0];
469 let mut engine = UniformPartitionEngine::new(&ir, block_size);
470
471 // Prime with non-zero input.
472 let mut scratch = vec![0.0f32; block_size];
473 engine.process(&[1.0f32; 4], &mut scratch);
474 engine.reset();
475
476 let input = [1.0f32, 2.0, 3.0, 4.0];
477 let mut after_reset = vec![0.0f32; block_size];
478 engine.process(&input, &mut after_reset);
479
480 let fresh = run_blocks(&ir, block_size, &[&input]);
481 for (e, y) in fresh.iter().zip(after_reset.iter()) {
482 assert!((e - y).abs() < EPSILON, "expected {e}, got {y}");
483 }
484 }
485
486 /// `load_ir()` installs a new IR and clears all delay state.
487 #[test]
488 fn load_ir_installs_new_coefficients() {
489 let block_size = 4;
490 let mut engine = UniformPartitionEngine::new(&[1.0f32, 0.0, 0.0, 0.0], block_size);
491
492 // Prime with non-zero input.
493 let mut scratch = vec![0.0f32; block_size];
494 engine.process(&[1.0f32; 4], &mut scratch);
495
496 // Install a gain-3 IR and verify output.
497 engine.load_ir(&[3.0f32, 0.0, 0.0, 0.0]);
498 let mut out = vec![0.0f32; block_size];
499 engine.process(&[1.0f32; 4], &mut out);
500 for &y in &out {
501 assert!((y - 3.0).abs() < EPSILON, "expected 3.0, got {y}");
502 }
503 }
504
505 // ── Accumulation mode ─────────────────────────────────────────────────────
506
507 /// `process()` must *add to* output, not overwrite it.
508 #[test]
509 fn process_accumulates_into_output() {
510 let block_size = 4;
511 let ir = [1.0f32, 0.0, 0.0, 0.0];
512 let mut engine = UniformPartitionEngine::new(&ir, block_size);
513 // Pre-fill output with 10.0.
514 let mut output = [10.0f32; 4];
515 engine.process(&[1.0f32; 4], &mut output);
516 // Each output sample should be 10 + 1*1 = 11.
517 for &y in &output {
518 assert!((y - 11.0).abs() < EPSILON, "expected 11.0, got {y}");
519 }
520 }
521}