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;