Skip to main content

nam_rs/models/
linear_fft.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2026 Fábio Henrique de Lima Silva (fhl.bsb@gmail.com) All rights reserved.
3
4//! Zero-latency partitioned FFT convolution state for the Linear model.
5//!
6//! Implements hybrid convolution: the direct head (first `P` samples) is
7//! computed in the time domain, while the tail (remaining `N-P` samples) is
8//! computed in the frequency domain via overlap-save FFT blocks.
9//!
10//! # Architecture
11//!
12//! The IR `h[n]` of length `N` is partitioned into:
13//! - **Head** (`h[0..P]`): convolved directly in the time domain with zero
14//!   latency (no block delay).
15//! - **Tail** (`h[P..N]`): partitioned into `K = ceil((N-P)/P)` blocks of
16//!   at most `P` samples each, zero-padded to `2P`, and pre-transformed
17//!   into the frequency domain via RFFT.
18//!
19//! At runtime, every `P` samples the input window of size `2P` is FFT'd
20//! and stored in a circular frequency delay line (FDL). The accumulated
21//! product of the FDL entries with the corresponding tail spectra is
22//! inverse-transformed, yielding `P` valid output samples that are buffered
23//! for per-sample consumption.
24//!
25//! # RT-Safety
26//! - All buffers are pre-allocated at construction (`AlignedVec<f32>`,
27//!   64-byte aligned for SIMD).
28//! - The `RfftPlanner` pre-computes twiddle/bit-reversal tables once.
29//! - Hot-path operations use only stack-allocated temporaries and
30//!   pre-existing slice references — zero heap allocation, zero locks.
31
32use crate::common::diagnostics::NamErrorCode;
33use crate::math::common::AlignedVec;
34use crate::math::common::dispatch::InstructionSet;
35use crate::math::common::dispatch::SimdMathConfig;
36use crate::math::dsp::fft::RfftPlanner;
37use core::fmt;
38
39/// State for zero-latency partitioned FFT (overlap-save) convolution.
40///
41/// Handles the tail portion of the impulse response (samples from partition
42/// size `P` to the end `N-1`). The head (samples 0..P) is computed directly
43/// in the time domain by `LinearModel::process_sample`.
44///
45/// # Buffer Layout
46///
47/// | Buffer            | Size              | Purpose                                   |
48/// |-------------------|-------------------|-------------------------------------------|
49/// | `h_fdl_re/im`     | `K × (P+1)`       | Pre-computed tail IR spectra (flat)       |
50/// | `fdl_re/im`       | `K × (P+1)`       | Circular frequency delay line (flat)      |
51/// | `input_buf`       | `2P`              | Input window for forward RFFT             |
52/// | `fft_re/im`       | `P+1`             | Forward RFFT output (compact spectrum)    |
53/// | `acc_re/im`       | `P+1`             | Complex MAC accumulation                  |
54/// | `output_buf`      | `2P`              | IFFT output (time domain)                 |
55/// | `tail_output_buf` | `P`               | Valid tail samples ready for consumption  |
56///
57/// where `K = ceil((N-P)/P)` is the number of tail partitions.
58///
59/// The spectrum buffers (`h_fdl_*` and `fdl_*`) are stored as flat
60/// `AlignedVec<f32>` with stride `P+1` (number of bins). Partition `k`
61/// occupies indices `[k * num_bins .. (k+1) * num_bins]`. This flat layout
62/// avoids pointer indirection in the hot-path MAC loop and keeps all FDL
63/// data in a single contiguous region for cache locality.
64pub struct LinearFftState {
65    /// Partition size `P` (head length = tail block size). Must be a power
66    /// of two ≤ `N`.
67    pub p: usize,
68    /// Total receptive field `N` (= IR length).
69    pub n: usize,
70    /// Number of tail partitions `K = ceil((N-P)/P)`.
71    pub num_partitions: usize,
72    /// Number of complex bins per partition = `P + 1`.
73    pub num_bins: usize,
74    /// Real-to-complex FFT planner for block size `2P`.
75    pub rfft: RfftPlanner<f32>,
76    /// Pre-computed real spectra of the tail IR partitions. Flat buffer of
77    /// length `K × num_bins`. Partition `k` starts at index `k * num_bins`.
78    pub h_fdl_re: AlignedVec<f32>,
79    /// Pre-computed imaginary spectra of the tail IR partitions.
80    pub h_fdl_im: AlignedVec<f32>,
81    /// Frequency delay line — real part. Flat circular buffer of past input
82    /// spectra, length `K × num_bins`. Partition `k` starts at `k * num_bins`.
83    pub fdl_re: AlignedVec<f32>,
84    /// Frequency delay line — imaginary part.
85    pub fdl_im: AlignedVec<f32>,
86    /// Circular write index into `fdl_re` / `fdl_im` (0..K-1).
87    /// Points to the next position that will be written. In `process_tail_block`,
88    /// the FDL is read before writing: old spectra are consumed for the tail
89    /// convolution, then the new input spectrum replaces the oldest entry.
90    pub fdl_write_idx: usize,
91    /// Input window buffer of size `2P` for the forward RFFT.
92    /// Filled from the `MirroredBuffer` history in `LinearModel`.
93    pub input_buf: AlignedVec<f32>,
94    /// Forward RFFT output — real bins (size `P+1`).
95    pub fft_re: AlignedVec<f32>,
96    /// Forward RFFT output — imaginary bins (size `P+1`).
97    pub fft_im: AlignedVec<f32>,
98    /// Complex MAC accumulation buffer — real part (size `P+1`).
99    pub acc_re: AlignedVec<f32>,
100    /// Complex MAC accumulation buffer — imaginary part (size `P+1`).
101    pub acc_im: AlignedVec<f32>,
102    /// IFFT output buffer (size `2P`). Valid tail samples reside in
103    /// indices `P..2P-1`.
104    pub output_buf: AlignedVec<f32>,
105    /// Circular buffer holding the `P` valid tail output samples from the
106    /// most recent `process_tail_block` call. Read sequentially by
107    /// `LinearModel::process_sample` in the FFT path.
108    pub tail_output_buf: AlignedVec<f32>,
109    /// Current read position within `tail_output_buf` (0 ≤ `sample_counter` < `P`).
110    /// Incremented by `LinearModel::process_sample`; triggers a new tail
111    /// block computation when it reaches `P`.
112    pub sample_counter: usize,
113    /// Instruction set captured at construction time to avoid runtime CPU
114    /// feature checks in the audio hot path.
115    pub isa: InstructionSet,
116}
117
118impl fmt::Debug for LinearFftState {
119    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
120        f.debug_struct("LinearFftState")
121            .field("p", &self.p)
122            .field("n", &self.n)
123            .field("num_partitions", &self.num_partitions)
124            .field("num_bins", &self.num_bins)
125            .field("fdl_write_idx", &self.fdl_write_idx)
126            .field("sample_counter", &self.sample_counter)
127            .finish_non_exhaustive()
128    }
129}
130
131impl LinearFftState {
132    /// Creates a new `LinearFftState` for the given impulse response.
133    ///
134    /// `p` is the partition size (head length), which must be a power of
135    /// two and ≤ `weights.len()`. `weights` are the IR samples in
136    /// **forward-time order** (i.e., `weights[0]` is the response at the
137    /// current sample). They are read-only: only the tail portion
138    /// (`weights[P..N]`) is used by this state; the head (`weights[0..P]`)
139    /// is convolved directly by `LinearModel`.
140    ///
141    /// All internal buffers are pre-allocated with 64-byte alignment
142    /// (`AlignedVec<f32>`) at construction time. No further heap
143    /// allocations occur during processing.
144    ///
145    /// # Panics
146    ///
147    /// Panics if `p` is not a power of two, or if `p > weights.len()`.
148    pub fn new(p: usize, weights: &[f32]) -> Result<Self, NamErrorCode> {
149        let n = weights.len();
150        assert!(p.is_power_of_two(), "P must be a power of two, got {p}");
151        assert!(p <= n, "P ({p}) must be ≤ N ({n})");
152
153        let block_size = 2 * p;
154        let num_bins = p + 1;
155        // ceil((N-P)/P), saturated at 0 when P==N
156        let num_partitions = if n > p { (n - p).div_ceil(p) } else { 0 };
157
158        let mut rfft = RfftPlanner::<f32>::new(block_size);
159
160        let fdl_total = num_partitions * num_bins;
161        let mut h_fdl_re = AlignedVec::<f32>::new(fdl_total, 0.0f32)?;
162        let mut h_fdl_im = AlignedVec::<f32>::new(fdl_total, 0.0f32)?;
163
164        // Reusable padded buffer for the forward RFFT (zero-padded IR segment)
165        let mut padded = AlignedVec::<f32>::new(block_size, 0.0f32)?;
166        let mut h_re = AlignedVec::<f32>::new(num_bins, 0.0f32)?;
167        let mut h_im = AlignedVec::<f32>::new(num_bins, 0.0f32)?;
168
169        for k in 0..num_partitions {
170            let start = p + k * p;
171            let end = (start + p).min(n);
172            let seg_len = end - start;
173
174            // Copy IR segment and zero-fill the tail of padded
175            for i in 0..seg_len {
176                padded[i] = weights[start + i];
177            }
178            padded[seg_len..].fill(0.0f32);
179
180            rfft.process_forward(&padded, &mut h_re, &mut h_im);
181
182            // Store spectrum at stride position k * num_bins
183            let offset = k * num_bins;
184            h_fdl_re[offset..offset + num_bins].copy_from_slice(&h_re);
185            h_fdl_im[offset..offset + num_bins].copy_from_slice(&h_im);
186        }
187
188        // Initialize FDL with zeros (no past input yet)
189        let fdl_re = AlignedVec::<f32>::new(fdl_total, 0.0f32)?;
190        let fdl_im = AlignedVec::<f32>::new(fdl_total, 0.0f32)?;
191
192        // fdl_write_idx starts at K-1 so that the first read uses all-zero
193        // FDL entries (correct for silence before the first block).
194        let fdl_write_idx = num_partitions.saturating_sub(1);
195        let isa = SimdMathConfig::current().instruction_set;
196
197        Ok(Self {
198            p,
199            n,
200            num_partitions,
201            num_bins,
202            rfft,
203            h_fdl_re,
204            h_fdl_im,
205            fdl_re,
206            fdl_im,
207            fdl_write_idx,
208            input_buf: AlignedVec::<f32>::new(block_size, 0.0f32)?,
209            fft_re: AlignedVec::<f32>::new(num_bins, 0.0f32)?,
210            fft_im: AlignedVec::<f32>::new(num_bins, 0.0f32)?,
211            acc_re: AlignedVec::<f32>::new(num_bins, 0.0f32)?,
212            acc_im: AlignedVec::<f32>::new(num_bins, 0.0f32)?,
213            output_buf: AlignedVec::<f32>::new(block_size, 0.0f32)?,
214            tail_output_buf: AlignedVec::<f32>::new(p, 0.0f32)?,
215            sample_counter: 0,
216            isa,
217        })
218    }
219
220    /// Returns a slice over the real spectrum of tail partition `k`.
221    ///
222    /// # Panics
223    ///
224    /// Panics if `k >= num_partitions`.
225    #[inline]
226    pub fn h_fdl_re_partition(&self, k: usize) -> &[f32] {
227        let offset = k * self.num_bins;
228        &self.h_fdl_re[offset..offset + self.num_bins]
229    }
230}
231
232mod process;
233
234#[cfg(test)]
235#[path = "linear_fft_test.rs"]
236mod tests;