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