//! Per-peer listener-side voice EQ. //! //! The EQ is deliberately small and local: three RBJ cookbook biquads at fixed //! voice-oriented frequencies, with only gain exposed to the UI. State lives per //! peer in the playout mixer so filter delay registers are continuous across 20ms //! Opus frames; flat settings are treated as bypass so the default path is cheap //! and sample-exact. use serde::{Deserialize, Serialize}; const DEFAULT_SAMPLE_RATE: f32 = 48_000.0; const LOW_SHELF_HZ: f32 = 160.0; const MID_PEAK_HZ: f32 = 2_400.0; const HIGH_SHELF_HZ: f32 = 6_500.0; const MID_Q: f32 = 1.0; const SHELF_Q: f32 = std::f32::consts::FRAC_1_SQRT_2; const FLAT_EPSILON_DB: f32 = 0.001; /// UI and config clamp for each band. Wide enough to be useful for voice, narrow /// enough that a peer cannot accidentally make the listener-side limiter do all /// the work. pub const EQ_GAIN_DB_MIN: f32 = -12.0; pub const EQ_GAIN_DB_MAX: f32 = 12.0; /// Persisted per-peer EQ gains, in decibels. `Default` is flat/bypassed. #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)] pub struct EqSettings { #[serde(default)] pub low_gain_db: f32, #[serde(default)] pub mid_gain_db: f32, #[serde(default)] pub high_gain_db: f32, } impl Default for EqSettings { fn default() -> Self { Self { low_gain_db: 0.0, mid_gain_db: 0.0, high_gain_db: 0.0, } } } impl EqSettings { pub fn flat() -> Self { Self::default() } /// Clamp all public gains to the supported UI/DSP range. pub fn clamped(self) -> Self { Self { low_gain_db: self.low_gain_db.clamp(EQ_GAIN_DB_MIN, EQ_GAIN_DB_MAX), mid_gain_db: self.mid_gain_db.clamp(EQ_GAIN_DB_MIN, EQ_GAIN_DB_MAX), high_gain_db: self.high_gain_db.clamp(EQ_GAIN_DB_MIN, EQ_GAIN_DB_MAX), } } /// True when the EQ should be bypassed entirely. pub fn is_flat(self) -> bool { self.low_gain_db.abs() <= FLAT_EPSILON_DB && self.mid_gain_db.abs() <= FLAT_EPSILON_DB && self.high_gain_db.abs() <= FLAT_EPSILON_DB } } /// A stateful three-band EQ. One instance belongs to one decoded peer stream. pub struct Eq { settings: EqSettings, low: Biquad, mid: Biquad, high: Biquad, } impl Eq { /// Build an EQ at the application's audio rate (48 kHz). pub fn new(settings: EqSettings) -> Self { Self::with_sample_rate(settings, DEFAULT_SAMPLE_RATE) } fn with_sample_rate(settings: EqSettings, sample_rate: f32) -> Self { let settings = settings.clamped(); Self { settings, low: Biquad::low_shelf(sample_rate, LOW_SHELF_HZ, settings.low_gain_db, SHELF_Q), mid: Biquad::peaking(sample_rate, MID_PEAK_HZ, settings.mid_gain_db, MID_Q), high: Biquad::high_shelf(sample_rate, HIGH_SHELF_HZ, settings.high_gain_db, SHELF_Q), } } pub fn settings(&self) -> EqSettings { self.settings } /// Process one mono PCM frame in place. Flat settings are sample-exact bypass. pub fn process_frame(&mut self, frame: &mut [i16]) { if self.settings.is_flat() { return; } for sample in frame { let x = *sample as f32; let y = self.high.process(self.mid.process(self.low.process(x))); *sample = y.round().clamp(i16::MIN as f32, i16::MAX as f32) as i16; } } } #[derive(Debug, Clone, Copy)] struct Coeffs { b0: f32, b1: f32, b2: f32, a1: f32, a2: f32, } impl Coeffs { fn normalized(b0: f32, b1: f32, b2: f32, a0: f32, a1: f32, a2: f32) -> Self { let inv_a0 = 1.0 / a0; Self { b0: b0 * inv_a0, b1: b1 * inv_a0, b2: b2 * inv_a0, a1: a1 * inv_a0, a2: a2 * inv_a0, } } fn all_finite(self) -> bool { self.b0.is_finite() && self.b1.is_finite() && self.b2.is_finite() && self.a1.is_finite() && self.a2.is_finite() } } /// Direct Form II transposed biquad. The two delay registers are the state that /// must survive across frames. struct Biquad { coeffs: Coeffs, z1: f32, z2: f32, } impl Biquad { fn new(coeffs: Coeffs) -> Self { debug_assert!(coeffs.all_finite()); Self { coeffs, z1: 0.0, z2: 0.0, } } fn low_shelf(sample_rate: f32, freq: f32, gain_db: f32, q: f32) -> Self { let (a, cos_w0, alpha) = rbj_terms(sample_rate, freq, gain_db, q); let sqrt_a = a.sqrt(); let b0 = a * ((a + 1.0) - (a - 1.0) * cos_w0 + 2.0 * sqrt_a * alpha); let b1 = 2.0 * a * ((a - 1.0) - (a + 1.0) * cos_w0); let b2 = a * ((a + 1.0) - (a - 1.0) * cos_w0 - 2.0 * sqrt_a * alpha); let a0 = (a + 1.0) + (a - 1.0) * cos_w0 + 2.0 * sqrt_a * alpha; let a1 = -2.0 * ((a - 1.0) + (a + 1.0) * cos_w0); let a2 = (a + 1.0) + (a - 1.0) * cos_w0 - 2.0 * sqrt_a * alpha; Self::new(Coeffs::normalized(b0, b1, b2, a0, a1, a2)) } fn peaking(sample_rate: f32, freq: f32, gain_db: f32, q: f32) -> Self { let (a, cos_w0, alpha) = rbj_terms(sample_rate, freq, gain_db, q); let b0 = 1.0 + alpha * a; let b1 = -2.0 * cos_w0; let b2 = 1.0 - alpha * a; let a0 = 1.0 + alpha / a; let a1 = -2.0 * cos_w0; let a2 = 1.0 - alpha / a; Self::new(Coeffs::normalized(b0, b1, b2, a0, a1, a2)) } fn high_shelf(sample_rate: f32, freq: f32, gain_db: f32, q: f32) -> Self { let (a, cos_w0, alpha) = rbj_terms(sample_rate, freq, gain_db, q); let sqrt_a = a.sqrt(); let b0 = a * ((a + 1.0) + (a - 1.0) * cos_w0 + 2.0 * sqrt_a * alpha); let b1 = -2.0 * a * ((a - 1.0) + (a + 1.0) * cos_w0); let b2 = a * ((a + 1.0) + (a - 1.0) * cos_w0 - 2.0 * sqrt_a * alpha); let a0 = (a + 1.0) - (a - 1.0) * cos_w0 + 2.0 * sqrt_a * alpha; let a1 = 2.0 * ((a - 1.0) - (a + 1.0) * cos_w0); let a2 = (a + 1.0) - (a - 1.0) * cos_w0 - 2.0 * sqrt_a * alpha; Self::new(Coeffs::normalized(b0, b1, b2, a0, a1, a2)) } fn process(&mut self, x: f32) -> f32 { let y = self.coeffs.b0 * x + self.z1; self.z1 = self.coeffs.b1 * x - self.coeffs.a1 * y + self.z2; self.z2 = self.coeffs.b2 * x - self.coeffs.a2 * y; // Avoid carrying denormal-sized state forever on long quiet tails. if self.z1.abs() < 1.0e-20 { self.z1 = 0.0; } if self.z2.abs() < 1.0e-20 { self.z2 = 0.0; } y } } fn rbj_terms(sample_rate: f32, freq: f32, gain_db: f32, q: f32) -> (f32, f32, f32) { let sr = sample_rate.max(1.0); let f = freq.clamp(1.0, sr * 0.49); let w0 = 2.0 * std::f32::consts::PI * f / sr; let a = 10.0f32.powf(gain_db / 40.0); let alpha = w0.sin() / (2.0 * q.max(0.001)); (a, w0.cos(), alpha) } #[cfg(test)] mod tests { use super::*; fn sine(freq: f32, len: usize, amp: f32) -> Vec { (0..len) .map(|n| { let t = n as f32 / DEFAULT_SAMPLE_RATE; (amp * (2.0 * std::f32::consts::PI * freq * t).sin()).round() as i16 }) .collect() } fn rms(frame: &[i16]) -> f32 { let sum: f32 = frame.iter().map(|&s| (s as f32).powi(2)).sum(); (sum / frame.len().max(1) as f32).sqrt() } #[test] fn flat_eq_is_sample_exact_identity() { let mut eq = Eq::new(EqSettings::flat()); let mut frame: Vec = (-480..480).map(|n| (n * 31) as i16).collect(); let original = frame.clone(); eq.process_frame(&mut frame); assert_eq!(frame, original); } #[test] fn low_shelf_boost_raises_low_frequency_energy() { let mut eq = Eq::new(EqSettings { low_gain_db: 9.0, ..EqSettings::flat() }); let mut low = sine(100.0, 48_000, 3_000.0); let before = rms(&low); eq.process_frame(&mut low); let after = rms(&low); assert!(after > before * 1.6, "low shelf should boost low RMS: {before} -> {after}"); } #[test] fn high_shelf_boost_raises_high_frequency_energy() { let mut eq = Eq::new(EqSettings { high_gain_db: 9.0, ..EqSettings::flat() }); let mut high = sine(8_000.0, 48_000, 3_000.0); let before = rms(&high); eq.process_frame(&mut high); let after = rms(&high); assert!(after > before * 1.6, "high shelf should boost high RMS: {before} -> {after}"); } #[test] fn coefficients_are_finite_across_supported_gain_range() { for gain in [EQ_GAIN_DB_MIN, -6.0, 0.0, 6.0, EQ_GAIN_DB_MAX] { for b in [ Biquad::low_shelf(DEFAULT_SAMPLE_RATE, LOW_SHELF_HZ, gain, SHELF_Q), Biquad::peaking(DEFAULT_SAMPLE_RATE, MID_PEAK_HZ, gain, MID_Q), Biquad::high_shelf(DEFAULT_SAMPLE_RATE, HIGH_SHELF_HZ, gain, SHELF_Q), ] { assert!(b.coeffs.all_finite(), "coefficients must be finite at {gain} dB"); } } } #[test] fn hot_signal_does_not_nan_or_wrap() { let mut eq = Eq::new(EqSettings { low_gain_db: 12.0, mid_gain_db: 12.0, high_gain_db: 12.0, }); let mut frame = sine(1_000.0, 48_000, 30_000.0); eq.process_frame(&mut frame); let peak = frame .iter() .map(|&s| i32::from(s).abs()) .max() .unwrap_or(0); assert!(peak > 1_000, "processed signal should retain audible energy"); assert!( frame.iter().any(|&s| s > 0) && frame.iter().any(|&s| s < 0), "a boosted sine should retain both polarities" ); } #[test] fn settings_are_clamped() { let s = EqSettings { low_gain_db: -99.0, mid_gain_db: 2.0, high_gain_db: 99.0, } .clamped(); assert_eq!(s.low_gain_db, EQ_GAIN_DB_MIN); assert_eq!(s.mid_gain_db, 2.0); assert_eq!(s.high_gain_db, EQ_GAIN_DB_MAX); } }