Skip to main content

alpha2_audio/
biquad.rs

1#![allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
2
3use core::f64::consts::PI;
4
5use crate::{ProcessCtx, Processor, StateBlob, StateError};
6
7/// Lowest frequency a filter renders, in hertz.
8pub const MIN_FREQUENCY_HZ: f32 = 10.0;
9
10/// Highest frequency a filter renders, as a fraction of the sample rate.
11pub const MAX_FREQUENCY_FRACTION: f32 = 0.49;
12
13/// The resonances a filter renders.
14pub const Q_RANGE: core::ops::RangeInclusive<f32> = 0.05..=50.0;
15
16/// Largest peaking gain a filter renders, in decibels either way.
17pub const MAX_PEAKING_GAIN_DB: f32 = 36.0;
18
19/// Common parametric equalizer shapes.
20#[derive(Copy, Clone, Debug, Eq, PartialEq)]
21pub enum BiquadKind {
22    /// Bell-shaped parametric band.
23    Peaking,
24    /// Second-order low-pass.
25    LowPass,
26    /// Second-order high-pass.
27    HighPass,
28}
29
30/// Two-channel transposed-direct-form-II biquad.
31///
32/// The coefficients are designed and the recursion runs in `f64`; samples go in and come out as
33/// `f32`. A low band's poles sit within about `2 * pi * f / rate` of the unit circle, which `f32`
34/// cannot resolve: at 96 kHz a 10 Hz low-pass in `f32` passes DC at +1.2 dB, and at 384 kHz and
35/// above its poles round onto or past the circle, so the filter goes silent or runs away. In `f64`
36/// every band of every rate up to 768 kHz is stable and within 0.1 dB of its design.
37#[derive(Clone, Debug)]
38pub struct Biquad {
39    kind: BiquadKind,
40    frequency: f32,
41    q: f32,
42    gain_db: f32,
43    /// `b0, b1, b2, a1, a2`, normalized by `a0`.
44    coefficients: [f64; 5],
45    z1: [f64; 2],
46    z2: [f64; 2],
47}
48
49impl Biquad {
50    /// Creates a parametric filter.
51    ///
52    /// Parameters are sanitized in [`Processor::prepare`]: non-finite values fall back to
53    /// 1 kHz, Q 0.707 and 0 dB, then each is clamped to the ranges this module exports.
54    #[must_use]
55    pub fn new(kind: BiquadKind, frequency: f32, q: f32, gain_db: f32) -> Self {
56        Self {
57            kind,
58            frequency,
59            q,
60            gain_db,
61            coefficients: [1.0, 0.0, 0.0, 0.0, 0.0],
62            z1: [0.0; 2],
63            z2: [0.0; 2],
64        }
65    }
66
67    fn update(&mut self, sample_rate: u32) {
68        // At sample rates below about 21 Hz the Nyquist cap falls under the floor. The floor wins
69        // so `clamp` cannot panic and a degenerate rate prepares like every other processor's.
70        let ceiling = (sample_rate as f32 * MAX_FREQUENCY_FRACTION).max(MIN_FREQUENCY_HZ);
71        let frequency = finite(self.frequency, 1_000.0).clamp(MIN_FREQUENCY_HZ, ceiling);
72        let q = finite(self.q, 0.707).clamp(*Q_RANGE.start(), *Q_RANGE.end());
73        let omega = 2.0 * PI * f64::from(frequency) / f64::from(sample_rate.max(1));
74        let (sin, cos) = libm::sincos(omega);
75        // 1 - cos(omega) = 2 sin^2(omega / 2), without the cancellation that loses a low band's
76        // precision.
77        let half_sin = libm::sin(omega / 2.0);
78        let one_minus_cos = 2.0 * half_sin * half_sin;
79        let alpha = sin / (2.0 * f64::from(q));
80        let (b0, b1, b2, a0, a1, a2) = match self.kind {
81            BiquadKind::LowPass => (
82                one_minus_cos / 2.0,
83                one_minus_cos,
84                one_minus_cos / 2.0,
85                1.0 + alpha,
86                -2.0 * cos,
87                1.0 - alpha,
88            ),
89            BiquadKind::HighPass => (
90                1.0_f64.midpoint(cos),
91                -(1.0 + cos),
92                1.0_f64.midpoint(cos),
93                1.0 + alpha,
94                -2.0 * cos,
95                1.0 - alpha,
96            ),
97            BiquadKind::Peaking => {
98                let amplitude = libm::pow(
99                    10.0,
100                    f64::from(
101                        finite(self.gain_db, 0.0).clamp(-MAX_PEAKING_GAIN_DB, MAX_PEAKING_GAIN_DB),
102                    ) / 40.0,
103                );
104                (
105                    1.0 + alpha * amplitude,
106                    -2.0 * cos,
107                    1.0 - alpha * amplitude,
108                    1.0 + alpha / amplitude,
109                    -2.0 * cos,
110                    1.0 - alpha / amplitude,
111                )
112            }
113        };
114        self.coefficients = [b0 / a0, b1 / a0, b2 / a0, a1 / a0, a2 / a0];
115    }
116}
117
118impl Biquad {
119    /// Runs one channel's block. When `guarded`, a non-finite input sample and any sample whose
120    /// output `f32` cannot hold play as silence and clear the state.
121    fn run(&mut self, channel: usize, input: &[f32], output: &mut [f32], guarded: bool) {
122        let [b0, b1, b2, a1, a2] = self.coefficients;
123        for (&input, output) in input.iter().zip(output.iter_mut()) {
124            let input = f64::from(if guarded { finite(input, 0.0) } else { input });
125            let value = flush_denormal_f64(input * b0 + self.z1[channel]);
126            let z1 = flush_denormal_f64(input * b1 + self.z2[channel] - a1 * value);
127            let z2 = flush_denormal_f64(input * b2 - a2 * value);
128            let sample = value as f32;
129            if guarded && !(sample.is_finite() && z1.is_finite() && z2.is_finite()) {
130                self.z1[channel] = 0.0;
131                self.z2[channel] = 0.0;
132                *output = 0.0;
133            } else {
134                self.z1[channel] = z1;
135                self.z2[channel] = z2;
136                *output = sample;
137            }
138        }
139    }
140}
141
142impl Processor for Biquad {
143    fn history_samples(&self) -> u32 {
144        let a1 = self.coefficients[3];
145        let a2 = self.coefficients[4];
146        let discriminant = a1 * a1 - 4.0 * a2;
147        let radius = if discriminant < 0.0 {
148            libm::sqrt(a2.abs())
149        } else {
150            a1.abs().midpoint(libm::sqrt(discriminant))
151        };
152        // Extra margin for the polynomial term of a repeated pole.
153        crate::processor::settling_samples(radius).saturating_mul(2)
154    }
155
156    fn prepare(&mut self, sample_rate: u32, _max_block: usize) {
157        self.update(sample_rate);
158    }
159
160    fn process(&mut self, _ctx: &ProcessCtx, inputs: &[&[f32]], outputs: &mut [&mut [f32]]) {
161        for channel in 0..inputs.len().min(outputs.len()).min(2) {
162            let state = (self.z1[channel], self.z2[channel]);
163            self.run(channel, inputs[channel], outputs[channel], false);
164            // A non-finite or overflowing sample would poison the recursion until a reset, so the
165            // block runs again playing each such sample as silence. Finite audio never takes this
166            // path.
167            let outputs_finite = outputs[channel]
168                [..inputs[channel].len().min(outputs[channel].len())]
169                .iter()
170                .all(|sample| sample.is_finite());
171            if !(outputs_finite && self.z1[channel].is_finite() && self.z2[channel].is_finite()) {
172                (self.z1[channel], self.z2[channel]) = state;
173                self.run(channel, inputs[channel], outputs[channel], true);
174            }
175        }
176    }
177
178    fn reset(&mut self) {
179        self.z1 = [0.0; 2];
180        self.z2 = [0.0; 2];
181    }
182    fn save_state(&self) -> StateBlob {
183        let mut bytes = Vec::with_capacity(32);
184        for value in self.z1.into_iter().chain(self.z2) {
185            bytes.extend_from_slice(&value.to_le_bytes());
186        }
187        StateBlob::new(bytes)
188    }
189    fn load_state(&mut self, state: &StateBlob) -> Result<(), StateError> {
190        let bytes: &[u8; 32] = state
191            .as_bytes()
192            .try_into()
193            .map_err(|_| StateError::Mismatch)?;
194        let values: [f64; 4] = core::array::from_fn(|index| {
195            f64::from_le_bytes(
196                bytes[index * 8..index * 8 + 8]
197                    .try_into()
198                    .expect("fixed range"),
199            )
200        });
201        if values.iter().any(|value| !value.is_finite()) {
202            return Err(StateError::Invalid);
203        }
204        self.z1 = [values[0], values[1]];
205        self.z2 = [values[2], values[3]];
206        Ok(())
207    }
208}
209
210fn finite(value: f32, fallback: f32) -> f32 {
211    if value.is_finite() { value } else { fallback }
212}
213pub(crate) fn flush_denormal(value: f32) -> f32 {
214    if value.abs() < 1.0e-30 { 0.0 } else { value }
215}
216/// [`flush_denormal`] for the filter's `f64` state: a decayed tail is zero long before it would be
217/// subnormal, so silence after a burst costs what silence from the start does.
218fn flush_denormal_f64(value: f64) -> f64 {
219    if value.abs() < 1.0e-30 { 0.0 } else { value }
220}
221
222#[cfg(test)]
223mod tests {
224    #![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
225
226    use super::{Biquad, BiquadKind};
227    use crate::{ProcessCtx, Processor, TransportState};
228
229    /// Every rate a filter runs at: the engine's project rates (8 to 96 kHz)
230    /// and the `AudioWorklet` binding's (3 to 768 kHz).
231    const RATES: [u32; 11] = [
232        3_000, 8_000, 11_025, 22_050, 44_100, 48_000, 88_200, 96_000, 192_000, 384_000, 768_000,
233    ];
234
235    /// The radius of the filter's larger pole.
236    fn pole_radius(filter: &Biquad) -> f64 {
237        let a1 = filter.coefficients[3];
238        let a2 = filter.coefficients[4];
239        let discriminant = a1 * a1 - 4.0 * a2;
240        if discriminant < 0.0 {
241            libm::sqrt(a2.abs())
242        } else {
243            a1.abs().midpoint(libm::sqrt(discriminant))
244        }
245    }
246
247    /// Runs `input` through `filter` in render quanta and returns the output.
248    fn run(filter: &mut Biquad, rate: u32, input: &[f32]) -> Vec<f32> {
249        let mut output = vec![0.0; input.len()];
250        for (block, (input, output)) in input.chunks(128).zip(output.chunks_mut(128)).enumerate() {
251            let sample_position = i64::try_from(block * 128).expect("position");
252            let ctx = ProcessCtx {
253                position: alpha2_time::MediaTime(0),
254                sample_position,
255                sample_rate: rate,
256                transport: TransportState::Playing,
257            };
258            filter.process(&ctx, &[input], &mut [output]);
259        }
260        output
261    }
262
263    /// The filter's gain in decibels where its ideal response is known exactly: a low-pass at DC
264    /// and a high-pass at Nyquist pass unity, and a peaking band has its gain at its centre.
265    fn measured_gain_db(kind: BiquadKind, frequency: f32, q: f32, gain_db: f32, rate: u32) -> f64 {
266        let mut filter = Biquad::new(kind, frequency, q, gain_db);
267        filter.prepare(rate, 128);
268        // Long enough for the slower pole to fall by e^-20, then a second of signal to measure.
269        let settle = libm::ceil(20.0 / -libm::log(pole_radius(&filter))) as usize;
270        let measure = rate as usize;
271        let input: Vec<f32> = (0..settle + measure)
272            .map(|index| match kind {
273                BiquadKind::LowPass => 0.25,
274                BiquadKind::HighPass => {
275                    if index % 2 == 0 {
276                        0.25
277                    } else {
278                        -0.25
279                    }
280                }
281                BiquadKind::Peaking => {
282                    let phase = core::f64::consts::TAU * f64::from(frequency) * index as f64
283                        / f64::from(rate);
284                    (0.25 * libm::sin(phase)) as f32
285                }
286            })
287            .collect();
288        let output = run(&mut filter, rate, &input);
289        let energy = |samples: &[f32]| {
290            samples
291                .iter()
292                .map(|sample| f64::from(*sample) * f64::from(*sample))
293                .sum::<f64>()
294        };
295        // A peaking band does not shift the phase at its centre, so the output's energy over the
296        // same samples compares exactly, whole cycles or not.
297        10.0 * libm::log10(energy(&output[settle..]) / energy(&input[settle..]))
298    }
299
300    #[test]
301    fn every_filter_is_stable_and_accurate_at_every_rate() {
302        let mut failures = Vec::new();
303        for rate in RATES {
304            for kind in [
305                BiquadKind::LowPass,
306                BiquadKind::HighPass,
307                BiquadKind::Peaking,
308            ] {
309                for frequency in [10.0, 20.0, 40.0] {
310                    for q in [0.05, 0.5, 0.707, 1.0, 4.0, 50.0] {
311                        let mut filter = Biquad::new(kind, frequency, q, 12.0);
312                        filter.prepare(rate, 128);
313                        let radius = pole_radius(&filter);
314                        if radius >= 1.0 {
315                            failures.push(format!(
316                                "{rate} Hz {kind:?} {frequency} Hz Q {q}: pole radius {radius}"
317                            ));
318                            continue;
319                        }
320                        // The gains need a settled filter; the extreme resonances take far longer
321                        // to settle than the sweep should run, and their poles are checked above.
322                        if !(0.5..=4.0).contains(&q) {
323                            continue;
324                        }
325                        let expected = if kind == BiquadKind::Peaking {
326                            12.0
327                        } else {
328                            0.0
329                        };
330                        let gain = measured_gain_db(kind, frequency, q, 12.0, rate);
331                        if (gain - expected).abs() > 0.1 {
332                            failures.push(format!(
333                                "{rate} Hz {kind:?} {frequency} Hz Q {q}: {gain:.3} dB, not {expected} dB"
334                            ));
335                        }
336                    }
337                }
338            }
339        }
340        assert!(failures.is_empty(), "{}", failures.join("\n"));
341    }
342}