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: ±4 gives ~24 dB of headroom above unity, which is
156        // enough for dense late reverb while preventing runaway feedback from
157        // producing non-finite output if the FDN matrix becomes unstable.
158        (out_l.clamp(-4.0, 4.0), out_r.clamp(-4.0, 4.0))
159    }
160
161    /// Reset all delay line state.
162    pub fn reset(&mut self) {
163        for dl in &mut self.delay_lines {
164            dl.fill(0.0);
165        }
166        self.write_positions.fill(0);
167        self.absorption_state.fill(0.0);
168    }
169}
170
171/// Generate a Hadamard matrix of size N (flattened, row-major).
172/// N must be a power of 2. The matrix is normalized by 1/sqrt(N).
173fn hadamard_flat(n: usize) -> Vec<f32> {
174    let mut h = vec![0.0f32; n * n];
175    h[0] = 1.0;
176
177    let mut size = 1;
178    while size < n {
179        // Copy top-left quadrant to other three quadrants
180        for i in 0..size {
181            for j in 0..size {
182                let val = h[i * n + j];
183                h[i * n + (j + size)] = val; // top-right
184                h[(i + size) * n + j] = val; // bottom-left
185                h[(i + size) * n + (j + size)] = -val; // bottom-right (negated)
186            }
187        }
188        size *= 2;
189    }
190
191    // Normalize
192    let scale = 1.0 / (n as f32).sqrt();
193    for v in &mut h {
194        *v *= scale;
195    }
196    h
197}
198
199/// Generate mutually-prime delay lengths for N delay lines.
200/// Uses small primes scaled to produce delays in the 20-80ms range.
201fn prime_delays(n: usize, sample_rate: u32) -> Vec<usize> {
202    // Target delay range: 20-80ms
203    let min_samples = (0.020 * sample_rate as f32) as usize;
204    let max_samples = (0.080 * sample_rate as f32) as usize;
205
206    // Primes in [min_samples, max_samples]
207    let primes: Vec<usize> = (min_samples..=max_samples)
208        .filter(|&n| is_prime(n))
209        .collect();
210
211    // Pick N evenly-spaced primes
212    if primes.len() >= n {
213        let step = primes.len() / n;
214        (0..n)
215            .map(|i| primes[(i * step).min(primes.len() - 1)])
216            .collect()
217    } else {
218        // Fallback: use primes near target delays
219        let base = (min_samples + max_samples) / 2;
220        (0..n).map(|i| next_prime(base + i * 7)).collect()
221    }
222}
223
224fn is_prime(n: usize) -> bool {
225    if n < 2 {
226        return false;
227    }
228    if n < 4 {
229        return true;
230    }
231    if n.is_multiple_of(2) || n.is_multiple_of(3) {
232        return false;
233    }
234    let mut i = 5;
235    while i * i <= n {
236        if n.is_multiple_of(i) || n.is_multiple_of(i + 2) {
237            return false;
238        }
239        i += 6;
240    }
241    true
242}
243
244fn next_prime(n: usize) -> usize {
245    let mut p = n;
246    while !is_prime(p) {
247        p += 1;
248    }
249    p
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    #[test]
257    fn test_hadamard_orthogonal() {
258        let n = 4;
259        let h = hadamard_flat(n);
260        // H * H^T should be identity (since H is normalized)
261        for i in 0..n {
262            for j in 0..n {
263                let mut dot = 0.0;
264                for k in 0..n {
265                    dot += h[i * n + k] * h[j * n + k];
266                }
267                let expected = if i == j { 1.0 } else { 0.0 };
268                assert!(
269                    (dot - expected).abs() < 1e-5,
270                    "H*H^T[{i}][{j}] = {dot}, expected {expected}"
271                );
272            }
273        }
274    }
275
276    #[test]
277    fn test_fdn_energy_decay() {
278        let mut fdn = Fdn::new(8, 48000);
279        fdn.set_room_params(1.0, 0.3, 1.0, 48000);
280
281        // Feed impulse
282        let (l, r) = fdn.process_stereo(1.0, 1.0);
283        let _ = (l, r);
284
285        // Process silence and measure energy decay
286        let mut energy = 0.0f32;
287        for _ in 0..48000 {
288            let (l, r) = fdn.process_stereo(0.0, 0.0);
289            energy += l * l + r * r;
290        }
291
292        // Should have produced some reverb energy
293        assert!(energy > 0.01, "FDN produced no energy: {energy}");
294        // But energy should be finite (stable)
295        assert!(energy.is_finite(), "FDN energy is not finite");
296    }
297
298    #[test]
299    fn test_fdn_stability() {
300        let mut fdn = Fdn::new(8, 48000);
301        fdn.set_room_params(3.0, 0.5, 1.5, 48000);
302
303        // Process many frames with input
304        for _ in 0..96000 {
305            let (l, r) = fdn.process_stereo(0.01, 0.01);
306            assert!(
307                l.is_finite() && r.is_finite(),
308                "Non-finite output: {l}, {r}"
309            );
310            assert!(l.abs() < 5.0 && r.abs() < 5.0, "Output too large: {l}, {r}");
311        }
312    }
313
314    #[test]
315    fn test_fdn_reset() {
316        let mut fdn = Fdn::new(4, 48000);
317        fdn.process_stereo(1.0, 1.0);
318        fdn.reset();
319        let (l, r) = fdn.process_stereo(0.0, 0.0);
320        assert!(
321            l.abs() < 1e-10 && r.abs() < 1e-10,
322            "State not cleared: {l}, {r}"
323        );
324    }
325
326    #[test]
327    fn test_prime_delays() {
328        let delays = prime_delays(8, 48000);
329        assert_eq!(delays.len(), 8);
330        // All should be prime
331        for &d in &delays {
332            assert!(is_prime(d), "{d} is not prime");
333        }
334        // All should be in 20-80ms range
335        for &d in &delays {
336            assert!((960..=3840).contains(&d), "Delay {d} out of range");
337        }
338    }
339}