nam_rs/models/linear.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//! Linear Model — Finite Impulse Response (FIR) network architecture for NAM.
5//!
6//! The Linear architecture implements a simple linear filter: the output at each
7//! time step is obtained by the dot product of the model weights with a window of
8//! input history (receptive field), plus a scalar bias:
9//!
10//! `output = bias + dot(weights, history_window)`
11//!
12//! Weights are stored in **reversed** order (matching C++ `nam::Linear` internal
13//! layout) so that a dot product with the oldest-to-newest history window yields
14//! the FIR convolution directly. The input history is stored in a
15//! `MirroredBuffer<f32>`, which provides branch-free, contiguous access via
16//! mirrored memory mapping — eliminating ring-buffer wrap-around logic in the
17//! audio hot-path.
18//!
19//! # C++ Parity
20//! This implementation matches `NeuralAmpModelerCore/NAM/dsp.cpp:255-301`
21//! exactly: JSON weights are reversed on construction, and the dot product is
22//! computed with the oldest-to-newest history window plus the scalar bias,
23//! without tanh or head_scale (those are exclusive to WaveNet).
24
25use super::NamModel;
26use super::linear_fft::LinearFftState;
27use super::sealed;
28use crate::common::diagnostics::NamErrorCode;
29use crate::dsp::mirror_buf::MirroredBuffer;
30use crate::loader::nam_json::LinearImplementation;
31use crate::math::common::AlignedVec;
32use log::warn;
33
34/// Runtime convolution mode for the Linear model.
35///
36/// Controls whether the model uses direct time-domain convolution or
37/// zero-latency partitioned FFT (hybrid: direct head + FFT tail).
38#[derive(Debug)]
39pub enum LinearMode {
40 /// Direct time-domain convolution — dot product over the full receptive field.
41 Direct,
42 /// FFT partitioned convolution with `LinearFftState` for the tail.
43 Fft(Box<LinearFftState>),
44}
45
46/// Linear Model — lightweight FIR-based neural model.
47///
48/// This is the simplest NAM architecture: a single linear layer (dot product)
49/// applied over the recent sample history with an optional scalar bias.
50///
51/// # RT-Safety
52/// - Zero allocation on the hot-path (`process`).
53/// - Uses `MirroredBuffer` for branch-free ring buffer access.
54/// - No locks, no `unwrap()`, no I/O.
55pub struct LinearModel {
56 /// FIR filter weights stored in **reversed** order (matching C++ internal
57 /// layout). JSON weights are reversed on construction, so that
58 /// `dot(weights, oldest_to_newest_window)` produces the FIR convolution.
59 /// 64-byte aligned for AVX2/AVX-512 SIMD loads.
60 pub weights: AlignedVec<f32>,
61 /// Scalar bias added after the dot product.
62 pub bias: f32,
63 /// Circular buffer of past input samples, backed by mirrored memory mapping
64 /// for branch-free contiguous access across the wrap boundary.
65 pub history: MirroredBuffer<f32>,
66 /// Current write position in the `history` ring buffer (0..receptive_field-1).
67 pub write_pos: usize,
68 /// Number of input samples in the receptive field (= `weights.len()`).
69 pub receptive_field: usize,
70 /// Precalculated limit * 2 to avoid runtime multiplication overflow checks.
71 double_limit: usize,
72 /// Whether to execute prewarm during `reset()`. Default: `true`.
73 pub prewarm_on_reset: bool,
74 /// Convolution implementation mode as configured in the JSON.
75 pub implementation: LinearImplementation,
76 /// Runtime convolution mode — `Direct` or `Fft` with partitioned FFT state.
77 pub mode: LinearMode,
78}
79
80/// Minimum receptive field (taps) for auto-selecting FFT partitioned convolution.
81///
82/// Below this threshold, time-domain direct convolution is more efficient
83/// due to FFT overhead.
84const FFT_AUTO_THRESHOLD: usize = 256;
85
86/// Largest power of two ≤ `n`.
87const fn largest_power_of_two_le(n: usize) -> usize {
88 if n == 0 {
89 return 0;
90 }
91 let mut v = n;
92 let mut r = 1;
93 while v > 1 {
94 r <<= 1;
95 v >>= 1;
96 }
97 r
98}
99
100/// Selects the partition size `P` for FFT hybrid convolution.
101///
102/// Returns the largest power of two ≤ `receptive_field / 2`, guaranteeing
103/// that `2 * P ≤ receptive_field` — which ensures the `block_start`
104/// subtraction never underflows in the hot-path.
105fn select_partition_size(receptive_field: usize) -> usize {
106 let max_p = receptive_field / 2;
107 largest_power_of_two_le(max_p.max(1))
108}
109
110impl LinearModel {
111 /// Creates a new LinearModel with the given weights, bias, and implementation.
112 ///
113 /// Weights are expected in **forward-time order** as stored in the `.nam`
114 /// JSON (`w[0]` is the response at the current sample). They are reversed
115 /// internally to match the C++ `nam::Linear` layout.
116 ///
117 /// `implementation` controls the convolution strategy (`Auto`, `Direct`, `Fft`)
118 /// as configured in the model's JSON:
119 /// - `Direct`: always uses time-domain dot product.
120 /// - `Auto`: uses FFT when `receptive_field >= 256`, otherwise Direct.
121 /// - `Fft`: uses FFT partitioned convolution; falls back to Direct with a
122 /// warning if the receptive field is too small (< 256).
123 ///
124 /// Allocates the `MirroredBuffer` for the input history. The buffer is
125 /// initialized to zero (silence) by the operating system via `mmap`.
126 ///
127 /// # Errors
128 /// Returns `std::io::Error` if the `MirroredBuffer` allocation fails
129 /// (e.g., out of memory or virtual address space).
130 pub fn new(
131 weights: Vec<f32>,
132 bias: f32,
133 implementation: LinearImplementation,
134 ) -> std::io::Result<Self> {
135 let receptive_field = weights.len();
136 let mode = Self::resolve_mode(implementation, receptive_field, &weights)
137 .map_err(|e| std::io::Error::new(std::io::ErrorKind::OutOfMemory, format!("{e}")))?;
138 let mut aligned = AlignedVec::from_vec(weights)
139 .map_err(|e| std::io::Error::new(std::io::ErrorKind::OutOfMemory, format!("{e}")))?;
140 aligned.reverse();
141 let history = MirroredBuffer::<f32>::new(receptive_field)?;
142 let limit = history.size();
143 let double_limit = limit.checked_mul(2).ok_or_else(|| {
144 std::io::Error::new(std::io::ErrorKind::InvalidInput, "Limit overflow")
145 })?;
146 Ok(Self {
147 weights: aligned,
148 bias,
149 history,
150 write_pos: limit,
151 receptive_field,
152 double_limit,
153 prewarm_on_reset: true,
154 implementation,
155 mode,
156 })
157 }
158
159 /// Resolves which convolution mode to use based on the requested
160 /// implementation and the receptive field size.
161 fn resolve_mode(
162 implementation: LinearImplementation,
163 receptive_field: usize,
164 weights: &[f32],
165 ) -> Result<LinearMode, NamErrorCode> {
166 match implementation {
167 LinearImplementation::Direct => Ok(LinearMode::Direct),
168 LinearImplementation::Auto => {
169 if receptive_field >= FFT_AUTO_THRESHOLD {
170 let p = select_partition_size(receptive_field);
171 if p < receptive_field {
172 return Ok(LinearMode::Fft(Box::new(LinearFftState::new(p, weights)?)));
173 }
174 }
175 Ok(LinearMode::Direct)
176 }
177 LinearImplementation::Fft => {
178 if receptive_field < FFT_AUTO_THRESHOLD {
179 warn!(
180 "[Linear] Fft requested but receptive_field={receptive_field} < {FFT_AUTO_THRESHOLD} \
181 — falling back to Direct"
182 );
183 return Ok(LinearMode::Direct);
184 }
185 let p = select_partition_size(receptive_field);
186 Ok(LinearMode::Fft(Box::new(LinearFftState::new(p, weights)?)))
187 }
188 }
189 }
190}
191
192mod process;
193
194impl sealed::Sealed for LinearModel {}
195
196impl NamModel for LinearModel {
197 #[inline(always)]
198 fn process(&mut self, input: &[f32], output: &mut [f32]) {
199 // SAFETY: weights are 64-byte aligned (AlignedVec).
200 unsafe { self.process(input, output) };
201 }
202
203 #[cold]
204 fn prewarm(&mut self, num_samples: usize) {
205 self.prewarm(num_samples);
206 }
207
208 fn reset(&mut self, sample_rate: u32, max_buffer_size: usize) -> anyhow::Result<()> {
209 if self.prewarm_on_reset {
210 self.reset(sample_rate, max_buffer_size);
211 }
212 Ok(())
213 }
214
215 fn prewarm_samples(&self) -> usize {
216 0
217 }
218
219 fn prewarm_on_reset(&self) -> bool {
220 self.prewarm_on_reset
221 }
222
223 fn set_prewarm_on_reset(&mut self, val: bool) {
224 self.prewarm_on_reset = val;
225 }
226}
227
228#[cfg(test)]
229#[path = "linear_test.rs"]
230mod tests;