Skip to main content

alpha2_audio/
reverb.rs

1#![allow(
2    clippy::cast_possible_truncation,
3    clippy::cast_precision_loss,
4    clippy::cast_sign_loss
5)]
6
7use crate::biquad::flush_denormal;
8use crate::{ProcessCtx, Processor, StateBlob, StateError};
9
10/// Allocation-free prepared Schroeder reverb with four combs and two allpasses.
11#[derive(Clone, Debug)]
12pub struct SchroederReverb {
13    room_size: f32,
14    /// The authored damping: the combs' one-pole coefficient at the
15    /// 44.1 kHz their lengths are given at.
16    damping: f32,
17    /// [`Self::damping`] at the prepared rate, so the filter keeps its time
18    /// constant in seconds as the lines keep their lengths.
19    damping_coefficient: f32,
20    wet: f32,
21    combs: Vec<DelayLine>,
22    allpasses: Vec<DelayLine>,
23    bypass: bool,
24}
25
26/// The loudest mono sample the lines take, far past any audio and far
27/// enough under `f32::MAX` that no comb or allpass state can overflow.
28const MAX_LINE_INPUT: f32 = 1.0e30;
29
30/// The rate the line lengths and the damping are given at.
31const REFERENCE_RATE: f64 = 44_100.0;
32/// Comb lengths at [`REFERENCE_RATE`], shortest first.
33const COMB_LENGTHS: [usize; 4] = [1116, 1188, 1277, 1356];
34/// Allpass lengths at [`REFERENCE_RATE`].
35const ALLPASS_LENGTHS: [usize; 2] = [556, 441];
36
37/// The rate the lines are sized for: `rate` itself, bounded by the highest
38/// rate any host runs the DSP at (the `AudioWorklet` binding takes every
39/// `AudioContext` rate up to 768 kHz), so a direct caller cannot size them
40/// from an absurd rate. Every rate in that range keeps the lines' lengths in
41/// seconds, so the echoes come at the same times; at 768 kHz the lines hold
42/// about 103 000 samples (413 KB), four times what they hold at 192 kHz.
43fn line_rate(rate: u32) -> u32 {
44    rate.clamp(1, crate::MAX_RESAMPLE_RATE)
45}
46
47/// A line `length` samples long at [`REFERENCE_RATE`], at `rate`.
48fn line_length(length: usize, rate: u32) -> usize {
49    (length as f32 * (rate as f32 / REFERENCE_RATE as f32)).round() as usize
50}
51
52/// The one-pole coefficient that damps at `rate` as `damping` does at
53/// [`REFERENCE_RATE`]: a coefficient d decays by d each sample, so
54/// d^(44100 / rate) decays as much each 1/44100 s.
55fn damping_at(damping: f32, rate: u32) -> f32 {
56    if damping > 0.0 && damping < 1.0 {
57        libm::pow(f64::from(damping), REFERENCE_RATE / f64::from(rate)) as f32
58    } else {
59        damping
60    }
61}
62
63#[derive(Clone, Debug)]
64struct DelayLine {
65    samples: Vec<f32>,
66    cursor: usize,
67    filter: f32,
68}
69
70impl DelayLine {
71    fn new(length: usize) -> Self {
72        Self {
73            samples: vec![0.0; length.max(1)],
74            cursor: 0,
75            filter: 0.0,
76        }
77    }
78    fn comb(&mut self, input: f32, feedback: f32, damping: f32) -> f32 {
79        let delayed = self.samples[self.cursor];
80        self.filter = flush_denormal(delayed * (1.0 - damping) + self.filter * damping);
81        self.samples[self.cursor] = flush_denormal(input + self.filter * feedback);
82        self.cursor = (self.cursor + 1) % self.samples.len();
83        delayed
84    }
85    fn allpass(&mut self, input: f32) -> f32 {
86        let delayed = self.samples[self.cursor];
87        let output = delayed - input;
88        self.samples[self.cursor] = flush_denormal(input + delayed * 0.5);
89        self.cursor = (self.cursor + 1) % self.samples.len();
90        output
91    }
92}
93
94impl SchroederReverb {
95    /// Creates a reverb with room, damping, and wet controls clamped to the unit interval.
96    /// Non-finite values fall back to 0.7, 0.3, and 0.25.
97    #[must_use]
98    pub fn new(room_size: f32, damping: f32, wet: f32) -> Self {
99        Self {
100            room_size: unit(room_size, 0.7),
101            damping: unit(damping, 0.3),
102            damping_coefficient: unit(damping, 0.3),
103            wet: unit(wet, 0.25),
104            combs: Vec::new(),
105            allpasses: Vec::new(),
106            bypass: false,
107        }
108    }
109    /// Enables transparent bypass while the tail continues evolving.
110    pub fn set_bypass(&mut self, bypass: bool) {
111        self.bypass = bypass;
112    }
113    /// How long the reverb rings after its input stops, in samples at
114    /// `sample_rate`, prepared or not: until its slowest frequencies (the
115    /// lowest, which the damping filter passes) have fallen 60 dB through
116    /// the longest comb, plus the allpasses' own 60 dB. A bound for these
117    /// room and damping values, not a fixed length: a room of 1 rings about
118    /// four seconds.
119    #[must_use]
120    pub fn tail_samples(&self, sample_rate: u32) -> u64 {
121        let safe_rate = line_rate(sample_rate);
122        let longest = line_length(COMB_LENGTHS[COMB_LENGTHS.len() - 1], safe_rate) as f64;
123        let damping = f64::from(damping_at(self.damping, safe_rate));
124        let comb = if damping >= 1.0 {
125            // The filter stays at zero, so a comb only repeats its input once.
126            longest
127        } else {
128            let feedback = f64::from(self.feedback());
129            // Each cycle loses ln(feedback) at low frequencies and takes the line plus the filter's
130            // group delay there.
131            let cycles = libm::log(1_000.0) / -libm::log(feedback);
132            longest + cycles * (longest + damping / (1.0 - damping))
133        };
134        // An allpass's feedback of 0.5 loses 60 dB in ten trips.
135        let allpasses = ALLPASS_LENGTHS
136            .into_iter()
137            .map(|length| line_length(length, safe_rate) as f64 * 10.0)
138            .sum::<f64>();
139        libm::ceil(comb + allpasses) as u64
140    }
141    /// The combs' feedback gain.
142    fn feedback(&self) -> f32 {
143        0.65 + self.room_size * 0.3
144    }
145}
146
147impl Processor for SchroederReverb {
148    fn history_samples(&self) -> u32 {
149        let delay = self
150            .combs
151            .iter()
152            .map(|line| line.samples.len())
153            .max()
154            .unwrap_or(0);
155        let delay = u32::try_from(delay).unwrap_or(u32::MAX);
156        let comb = if self.damping_coefficient >= 1.0 {
157            // The zero-initialized feedback filter never changes in this case.
158            f64::from(delay)
159        } else {
160            let damping = f64::from(self.damping_coefficient);
161            let feedback = f64::from(self.feedback());
162            (f64::from(delay) + 1.0 / (1.0 - damping)) * 32.0 / (1.0 - feedback)
163        };
164        let comb = libm::ceil(comb) as u32;
165        self.allpasses.iter().fold(comb, |total, line| {
166            total.saturating_add(
167                u32::try_from(line.samples.len())
168                    .unwrap_or(u32::MAX)
169                    .saturating_mul(crate::processor::settling_samples(0.5)),
170            )
171        })
172    }
173
174    fn prepare(&mut self, sample_rate: u32, _max_block: usize) {
175        let safe_rate = line_rate(sample_rate);
176        self.combs = COMB_LENGTHS
177            .into_iter()
178            .map(|length| DelayLine::new(line_length(length, safe_rate)))
179            .collect();
180        self.allpasses = ALLPASS_LENGTHS
181            .into_iter()
182            .map(|length| DelayLine::new(line_length(length, safe_rate)))
183            .collect();
184        self.damping_coefficient = damping_at(self.damping, safe_rate);
185    }
186    fn process(&mut self, _ctx: &ProcessCtx, inputs: &[&[f32]], outputs: &mut [&mut [f32]]) {
187        let channels = inputs.len().min(outputs.len()).min(2);
188        let frames = inputs
189            .iter()
190            .take(channels)
191            .map(|v| v.len())
192            .chain(outputs.iter().take(channels).map(|v| v.len()))
193            .min()
194            .unwrap_or(0);
195        if self.combs.is_empty() {
196            return;
197        }
198        for frame in 0..frames {
199            let mono = inputs[..channels]
200                .iter()
201                .map(|channel| channel[frame])
202                .sum::<f32>()
203                / channels.max(1) as f32;
204            // The lines feed back on themselves, so a value they cannot hold would stay (as NaN)
205            // until a reset. A non-finite sum is silence, and a finite one is held where no line
206            // can overflow: a comb's gain is under 1 / (1 - 0.95) = 20 and an allpass's under 2.
207            let mono = if mono.is_finite() {
208                mono.clamp(-MAX_LINE_INPUT, MAX_LINE_INPUT)
209            } else {
210                0.0
211            };
212            let (feedback, damping) = (self.feedback(), self.damping_coefficient);
213            let mut reverberant = self
214                .combs
215                .iter_mut()
216                .map(|comb| comb.comb(mono, feedback, damping))
217                .sum::<f32>()
218                / self.combs.len() as f32;
219            for allpass in &mut self.allpasses {
220                reverberant = allpass.allpass(reverberant);
221            }
222            for channel in 0..channels {
223                let input = finite_or_zero(inputs[channel][frame]);
224                outputs[channel][frame] = if self.bypass {
225                    input
226                } else {
227                    input * (1.0 - self.wet) + reverberant * self.wet
228                };
229            }
230        }
231    }
232    fn reset(&mut self) {
233        for delay in self.combs.iter_mut().chain(&mut self.allpasses) {
234            delay.samples.fill(0.0);
235            delay.cursor = 0;
236            delay.filter = 0.0;
237        }
238    }
239    fn save_state(&self) -> StateBlob {
240        let mut bytes = Vec::new();
241        for delay in self.combs.iter().chain(&self.allpasses) {
242            bytes.extend_from_slice(&(u64::try_from(delay.cursor).unwrap_or(0)).to_le_bytes());
243            bytes.extend_from_slice(&delay.filter.to_le_bytes());
244            for sample in &delay.samples {
245                bytes.extend_from_slice(&sample.to_le_bytes());
246            }
247        }
248        StateBlob::new(bytes)
249    }
250    fn load_state(&mut self, state: &StateBlob) -> Result<(), StateError> {
251        let bytes = state.as_bytes();
252        // Every line's part is its cursor, its filter, then its samples: check them all before
253        // taking any.
254        let expected = self
255            .combs
256            .iter()
257            .chain(&self.allpasses)
258            .map(|delay| 12 + delay.samples.len() * 4)
259            .sum::<usize>();
260        if bytes.len() != expected {
261            return Err(StateError::Mismatch);
262        }
263        let mut offset = 0;
264        for delay in self.combs.iter().chain(&self.allpasses) {
265            let cursor = usize::try_from(u64::from_le_bytes(
266                bytes[offset..offset + 8].try_into().expect("fixed range"),
267            ))
268            .ok();
269            let values = bytes[offset + 8..offset + 12 + delay.samples.len() * 4]
270                .as_chunks::<4>()
271                .0
272                .iter()
273                .map(|word| f32::from_le_bytes(*word));
274            if cursor.is_none_or(|value| value >= delay.samples.len())
275                || values.clone().any(|value| !value.is_finite())
276            {
277                return Err(StateError::Invalid);
278            }
279            offset += 12 + delay.samples.len() * 4;
280        }
281        let mut offset = 0;
282        for delay in self.combs.iter_mut().chain(&mut self.allpasses) {
283            let mut values = bytes[offset + 8..offset + 12 + delay.samples.len() * 4]
284                .as_chunks::<4>()
285                .0
286                .iter()
287                .map(|word| f32::from_le_bytes(*word));
288            delay.cursor = usize::try_from(u64::from_le_bytes(
289                bytes[offset..offset + 8].try_into().expect("fixed range"),
290            ))
291            .unwrap_or(0);
292            delay.filter = values.next().unwrap_or(0.0);
293            for (target, value) in delay.samples.iter_mut().zip(values) {
294                *target = value;
295            }
296            offset += 12 + delay.samples.len() * 4;
297        }
298        Ok(())
299    }
300}
301
302fn unit(value: f32, fallback: f32) -> f32 {
303    if value.is_finite() {
304        value.clamp(0.0, 1.0)
305    } else {
306        fallback
307    }
308}
309
310fn finite_or_zero(value: f32) -> f32 {
311    if value.is_finite() { value } else { 0.0 }
312}
313
314#[cfg(test)]
315mod tests {
316    use super::SchroederReverb;
317    use crate::{ProcessCtx, Processor, TransportState};
318    use alpha2_time::MediaTime;
319
320    /// The impulse response of a fully wet reverb, `seconds` long.
321    fn impulse_response(room_size: f32, damping: f32, sample_rate: u32, seconds: f64) -> Vec<f32> {
322        let mut reverb = SchroederReverb::new(room_size, damping, 1.0);
323        reverb.prepare(sample_rate, 512);
324        let ctx = ProcessCtx {
325            position: MediaTime::ZERO,
326            sample_position: 0,
327            sample_rate,
328            transport: TransportState::Playing,
329        };
330        let frames = (f64::from(sample_rate) * seconds) as usize;
331        let mut input = vec![0.0; frames];
332        input[0] = 1.0;
333        let mut output = vec![0.0; frames];
334        for (input, output) in input.chunks(512).zip(output.chunks_mut(512)) {
335            reverb.process(&ctx, &[input], &mut [output]);
336        }
337        output
338    }
339
340    /// How fast `response` decays around `frequency`, in dB a second:
341    /// the energy through a fourth-order band-pass (two RBJ sections,
342    /// Q 1.414) over 0.2 to 0.4 s against 0.6 to 0.8 s.
343    fn band_decay_db_per_second(response: &[f32], sample_rate: u32, frequency: f64) -> f64 {
344        let omega = core::f64::consts::TAU * frequency / f64::from(sample_rate);
345        let alpha = libm::sin(omega) / (2.0 * 1.414);
346        let a0 = 1.0 + alpha;
347        let (b0, b2, a1, a2) = (
348            alpha / a0,
349            -alpha / a0,
350            -2.0 * libm::cos(omega) / a0,
351            (1.0 - alpha) / a0,
352        );
353        let mut signal = response
354            .iter()
355            .map(|&sample| f64::from(sample))
356            .collect::<Vec<_>>();
357        for _ in 0..2 {
358            let (mut x1, mut x2, mut y1, mut y2) = (0.0, 0.0, 0.0, 0.0);
359            for sample in &mut signal {
360                let y = b0 * *sample + b2 * x2 - a1 * y1 - a2 * y2;
361                (x2, x1, y2, y1) = (x1, *sample, y1, y);
362                *sample = y;
363            }
364        }
365        let energy = |from: f64, to: f64| {
366            let rate = f64::from(sample_rate);
367            signal[(from * rate) as usize..(to * rate) as usize]
368                .iter()
369                .map(|sample| sample * sample)
370                .sum::<f64>()
371        };
372        10.0 * libm::log10(energy(0.2, 0.4) / energy(0.6, 0.8)) / 0.4
373    }
374
375    #[test]
376    fn a_room_decays_alike_in_every_band_at_any_project_rate() {
377        for (room_size, damping) in [(0.7, 0.3), (0.7, 0.5), (0.9, 0.2)] {
378            for frequency in [500.0, 5_000.0, 8_000.0] {
379                let reference = band_decay_db_per_second(
380                    &impulse_response(room_size, damping, 48_000, 1.0),
381                    48_000,
382                    frequency,
383                );
384                for sample_rate in [44_100, 96_000] {
385                    let decay = band_decay_db_per_second(
386                        &impulse_response(room_size, damping, sample_rate, 1.0),
387                        sample_rate,
388                        frequency,
389                    );
390                    let difference = (decay / reference - 1.0).abs();
391                    assert!(
392                        difference < 0.08,
393                        "room {room_size} damping {damping} at {frequency} Hz: {decay:.1} dB/s at {sample_rate} Hz, {reference:.1} at 48 kHz"
394                    );
395                }
396            }
397        }
398    }
399
400    #[test]
401    fn the_tail_bound_outlasts_every_room_without_overstating_it() {
402        for sample_rate in [44_100, 48_000, 96_000] {
403            for room_size in [1.0, 0.95, 0.9, 0.5, 0.0] {
404                for damping in [0.0, 0.3, 0.7, 1.0] {
405                    let response = impulse_response(room_size, damping, sample_rate, 5.5);
406                    let peak = response
407                        .iter()
408                        .fold(0.0_f32, |peak, sample| peak.max(sample.abs()));
409                    // The last sample within 60 dB of the peak.
410                    let rings = response
411                        .iter()
412                        .rposition(|sample| sample.abs() >= peak * 1.0e-3)
413                        .expect("a response") as u64;
414                    assert!(
415                        rings < response.len() as u64 - u64::from(sample_rate) / 4,
416                        "the response is long enough to measure"
417                    );
418                    let mut reverb = SchroederReverb::new(room_size, damping, 1.0);
419                    let tail = reverb.tail_samples(sample_rate);
420                    reverb.prepare(sample_rate, 512);
421                    assert_eq!(reverb.tail_samples(sample_rate), tail, "prepared or not");
422                    assert!(
423                        rings <= tail,
424                        "room {room_size} damping {damping} at {sample_rate} Hz rings {rings} samples, past {tail}"
425                    );
426                    assert!(
427                        tail <= rings * 3,
428                        "room {room_size} damping {damping} at {sample_rate} Hz rings {rings} samples, far under {tail}"
429                    );
430                }
431            }
432        }
433    }
434
435    #[test]
436    fn a_decaying_tail_never_leaves_subnormal_state() {
437        // A small, bright room empties its combs within seconds, after which the allpasses only
438        // halve what they hold.
439        let mut reverb = SchroederReverb::new(0.0, 0.0, 1.0);
440        reverb.prepare(48_000, 128);
441        let ctx = ProcessCtx {
442            position: MediaTime::ZERO,
443            sample_position: 0,
444            sample_rate: 48_000,
445            transport: TransportState::Playing,
446        };
447        let burst = [0.5_f32; 128];
448        let silence = [0.0_f32; 128];
449        let mut output = [0.0_f32; 128];
450        reverb.process(&ctx, &[&burst], &mut [&mut output]);
451        for block in 0..3_750 {
452            reverb.process(&ctx, &[&silence], &mut [&mut output]);
453            let subnormal = reverb
454                .combs
455                .iter()
456                .chain(&reverb.allpasses)
457                .flat_map(|line| line.samples.iter().chain([&line.filter]))
458                .find(|value| value.is_subnormal());
459            assert_eq!(subnormal, None, "block {block}");
460        }
461    }
462}