Skip to main content

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}