Skip to main content

math_audio_dsp/
fdn.rs

1//! Feedback Delay Network (FDN) for Late Reverb Synthesis
2//!
3//! Provides a parametric late reverb tail using N delay lines with a unitary
4//! feedback matrix. Used by the binaural plugin to extend early reflections
5//! into a full room impulse response.
6//!
7//! Design: Hadamard feedback matrix (energy-preserving), mutually-prime delay
8//! lengths, per-line 1-pole absorption filters for frequency-dependent decay.
9//!
10//! Reference: Jot, J.M. & Chaigne, A. (1991). "Digital Delay Networks for
11//! Designing Artificial Reverberators."
12
13/// Feedback Delay Network for late reverb synthesis.
14///
15/// N delay lines (default 8) with a Hadamard mixing matrix for energy-preserving
16/// feedback, per-line lowpass absorption for frequency-dependent decay, and
17/// stereo output tapping.
18pub struct Fdn {
19    num_lines: usize,
20    delay_lines: Vec<Vec<f32>>,
21    delay_lengths: Vec<usize>,
22    write_positions: Vec<usize>,
23    /// Hadamard feedback matrix (flattened NxN, row-major)
24    feedback_matrix: Vec<f32>,
25    /// Global feedback gain (< 1.0 for stability)
26    feedback_gain: f32,
27    /// Per-line 1-pole lowpass absorption: state
28    absorption_state: Vec<f32>,
29    /// Per-line absorption coefficient (0..1, higher = more HF damping)
30    absorption_coeff: Vec<f32>,
31    /// Input gains per delay line
32    input_gains: Vec<f32>,
33    /// Output taps: [line][2] for stereo (left, right)
34    output_gains: Vec<[f32; 2]>,
35    /// Pre-allocated scratch for reading delay line outputs before feedback
36    scratch: Vec<f32>,
37}
38
39impl Fdn {
40    /// Create a new FDN with `num_lines` delay lines.
41    ///
42    /// `num_lines` must be a power of 2 (for Hadamard matrix). Typical: 8 or 16.
43    pub fn new(num_lines: usize, sample_rate: u32) -> Self {
44        let num_lines = num_lines.next_power_of_two().max(2);
45
46        // Mutually-prime delay lengths based on room size ~medium room
47        let base_delays = prime_delays(num_lines, sample_rate);
48        let delay_lines: Vec<Vec<f32>> = base_delays.iter().map(|&len| vec![0.0; len]).collect();
49
50        let feedback_matrix = hadamard_flat(num_lines);
51        let scale = 1.0 / (num_lines as f32).sqrt();
52
53        // Distribute input and output across lines
54        let input_gains = vec![scale; num_lines];
55        let output_gains: Vec<[f32; 2]> = (0..num_lines)
56            .map(|i| {
57                let angle = std::f32::consts::PI * i as f32 / num_lines as f32;
58                [angle.cos(), angle.sin()]
59            })
60            .collect();
61
62        Self {
63            num_lines,
64            delay_lines,
65            delay_lengths: base_delays,
66            write_positions: vec![0; num_lines],
67            feedback_matrix,
68            feedback_gain: 0.7,
69            absorption_state: vec![0.0; num_lines],
70            absorption_coeff: vec![0.3; num_lines],
71            input_gains,
72            output_gains,
73            scratch: vec![0.0; num_lines],
74        }
75    }
76
77    /// Configure room parameters.
78    ///
79    /// `rt60`: Reverb time in seconds (0.1 - 10.0)
80    /// `damping`: HF damping (0.0 = bright, 1.0 = dark)
81    /// `size`: Room size factor (0.5 = small, 2.0 = large)
82    pub fn set_room_params(&mut self, rt60: f32, damping: f32, size: f32, sample_rate: u32) {
83        // Scale delay lengths by room size
84        let base_delays = prime_delays(self.num_lines, sample_rate);
85        for (i, &base) in base_delays.iter().enumerate() {
86            let new_len = ((base as f32 * size) as usize).max(1);
87            if new_len != self.delay_lengths[i] {
88                self.delay_lengths[i] = new_len;
89                self.delay_lines[i].resize(new_len, 0.0);
90                self.write_positions[i] %= new_len;
91            }
92        }
93
94        // Feedback gain from RT60: g = 10^(-3 * delay / (RT60 * SR))
95        // Use the average delay length for the global gain
96        let avg_delay = self.delay_lengths.iter().sum::<usize>() as f32 / self.num_lines as f32;
97        let rt60_samples = rt60 * sample_rate as f32;
98        self.feedback_gain = if rt60_samples > 0.0 {
99            10.0_f32
100                .powf(-3.0 * avg_delay / rt60_samples)
101                .clamp(0.0, 0.999)
102        } else {
103            0.0
104        };
105
106        // Absorption from damping
107        let damp = damping.clamp(0.0, 1.0);
108        for coeff in &mut self.absorption_coeff {
109            *coeff = damp * 0.6 + 0.1; // 0.1 (bright) to 0.7 (dark)
110        }
111    }
112
113    /// Process one stereo sample pair through the FDN.
114    ///
115    /// Input is mixed into all delay lines. Output is tapped from all lines.
116    #[inline]
117    pub fn process_stereo(&mut self, left: f32, right: f32) -> (f32, f32) {
118        let n = self.num_lines;
119        let mono_in = (left + right) * 0.5;
120
121        // Read current outputs from all delay lines
122        for i in 0..n {
123            let pos = self.write_positions[i];
124            self.scratch[i] = self.delay_lines[i][pos];
125        }
126
127        // Compute feedback: new_input[i] = sum_j(matrix[i][j] * scratch[j]) * feedback_gain
128        // Plus input contribution
129        for i in 0..n {
130            let mut feedback_sum = 0.0;
131            for j in 0..n {
132                feedback_sum += self.feedback_matrix[i * n + j] * self.scratch[j];
133            }
134            let input = mono_in * self.input_gains[i];
135            let fb = feedback_sum * self.feedback_gain;
136
137            // 1-pole absorption: lowpass the feedback
138            let alpha = self.absorption_coeff[i];
139            self.absorption_state[i] = alpha * self.absorption_state[i] + (1.0 - alpha) * fb;
140
141            // Write to delay line
142            let pos = self.write_positions[i];
143            self.delay_lines[i][pos] = input + self.absorption_state[i];
144            self.write_positions[i] = (pos + 1) % self.delay_lengths[i];
145        }
146
147        // Tap outputs (stereo)
148        let mut out_l = 0.0;
149        let mut out_r = 0.0;
150        for i in 0..n {
151            out_l += self.scratch[i] * self.output_gains[i][0];
152            out_r += self.scratch[i] * self.output_gains[i][1];
153        }
154
155        // Safety clamp
156        (out_l.clamp(-4.0, 4.0), out_r.clamp(-4.0, 4.0))
157    }
158
159    /// Reset all delay line state.
160    pub fn reset(&mut self) {
161        for dl in &mut self.delay_lines {
162            dl.fill(0.0);
163        }
164        self.write_positions.fill(0);
165        self.absorption_state.fill(0.0);
166    }
167}
168
169/// Generate a Hadamard matrix of size N (flattened, row-major).
170/// N must be a power of 2. The matrix is normalized by 1/sqrt(N).
171fn hadamard_flat(n: usize) -> Vec<f32> {
172    let mut h = vec![0.0f32; n * n];
173    h[0] = 1.0;
174
175    let mut size = 1;
176    while size < n {
177        // Copy top-left quadrant to other three quadrants
178        for i in 0..size {
179            for j in 0..size {
180                let val = h[i * n + j];
181                h[i * n + (j + size)] = val; // top-right
182                h[(i + size) * n + j] = val; // bottom-left
183                h[(i + size) * n + (j + size)] = -val; // bottom-right (negated)
184            }
185        }
186        size *= 2;
187    }
188
189    // Normalize
190    let scale = 1.0 / (n as f32).sqrt();
191    for v in &mut h {
192        *v *= scale;
193    }
194    h
195}
196
197/// Generate mutually-prime delay lengths for N delay lines.
198/// Uses small primes scaled to produce delays in the 20-80ms range.
199fn prime_delays(n: usize, sample_rate: u32) -> Vec<usize> {
200    // Target delay range: 20-80ms
201    let min_samples = (0.020 * sample_rate as f32) as usize;
202    let max_samples = (0.080 * sample_rate as f32) as usize;
203
204    // Primes in [min_samples, max_samples]
205    let primes: Vec<usize> = (min_samples..=max_samples)
206        .filter(|&n| is_prime(n))
207        .collect();
208
209    // Pick N evenly-spaced primes
210    if primes.len() >= n {
211        let step = primes.len() / n;
212        (0..n)
213            .map(|i| primes[(i * step).min(primes.len() - 1)])
214            .collect()
215    } else {
216        // Fallback: use primes near target delays
217        let base = (min_samples + max_samples) / 2;
218        (0..n).map(|i| next_prime(base + i * 7)).collect()
219    }
220}
221
222fn is_prime(n: usize) -> bool {
223    if n < 2 {
224        return false;
225    }
226    if n < 4 {
227        return true;
228    }
229    if n.is_multiple_of(2) || n.is_multiple_of(3) {
230        return false;
231    }
232    let mut i = 5;
233    while i * i <= n {
234        if n.is_multiple_of(i) || n.is_multiple_of(i + 2) {
235            return false;
236        }
237        i += 6;
238    }
239    true
240}
241
242fn next_prime(n: usize) -> usize {
243    let mut p = n;
244    while !is_prime(p) {
245        p += 1;
246    }
247    p
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253
254    #[test]
255    fn test_hadamard_orthogonal() {
256        let n = 4;
257        let h = hadamard_flat(n);
258        // H * H^T should be identity (since H is normalized)
259        for i in 0..n {
260            for j in 0..n {
261                let mut dot = 0.0;
262                for k in 0..n {
263                    dot += h[i * n + k] * h[j * n + k];
264                }
265                let expected = if i == j { 1.0 } else { 0.0 };
266                assert!(
267                    (dot - expected).abs() < 1e-5,
268                    "H*H^T[{i}][{j}] = {dot}, expected {expected}"
269                );
270            }
271        }
272    }
273
274    #[test]
275    fn test_fdn_energy_decay() {
276        let mut fdn = Fdn::new(8, 48000);
277        fdn.set_room_params(1.0, 0.3, 1.0, 48000);
278
279        // Feed impulse
280        let (l, r) = fdn.process_stereo(1.0, 1.0);
281        let _ = (l, r);
282
283        // Process silence and measure energy decay
284        let mut energy = 0.0f32;
285        for _ in 0..48000 {
286            let (l, r) = fdn.process_stereo(0.0, 0.0);
287            energy += l * l + r * r;
288        }
289
290        // Should have produced some reverb energy
291        assert!(energy > 0.01, "FDN produced no energy: {energy}");
292        // But energy should be finite (stable)
293        assert!(energy.is_finite(), "FDN energy is not finite");
294    }
295
296    #[test]
297    fn test_fdn_stability() {
298        let mut fdn = Fdn::new(8, 48000);
299        fdn.set_room_params(3.0, 0.5, 1.5, 48000);
300
301        // Process many frames with input
302        for _ in 0..96000 {
303            let (l, r) = fdn.process_stereo(0.01, 0.01);
304            assert!(
305                l.is_finite() && r.is_finite(),
306                "Non-finite output: {l}, {r}"
307            );
308            assert!(l.abs() < 5.0 && r.abs() < 5.0, "Output too large: {l}, {r}");
309        }
310    }
311
312    #[test]
313    fn test_fdn_reset() {
314        let mut fdn = Fdn::new(4, 48000);
315        fdn.process_stereo(1.0, 1.0);
316        fdn.reset();
317        let (l, r) = fdn.process_stereo(0.0, 0.0);
318        assert!(
319            l.abs() < 1e-10 && r.abs() < 1e-10,
320            "State not cleared: {l}, {r}"
321        );
322    }
323
324    #[test]
325    fn test_prime_delays() {
326        let delays = prime_delays(8, 48000);
327        assert_eq!(delays.len(), 8);
328        // All should be prime
329        for &d in &delays {
330            assert!(is_prime(d), "{d} is not prime");
331        }
332        // All should be in 20-80ms range
333        for &d in &delays {
334            assert!((960..=3840).contains(&d), "Delay {d} out of range");
335        }
336    }
337}