The repo never enforced rustfmt, so formatting had drifted broadly. This is a single mechanical `cargo fmt` pass over the whole crate (no behavioral change; lib suite green, 493 passed). Going forward fmt should be enforced (planned CI fmt --check step). Part of the 0.6.1 hygiene pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
325 lines
10 KiB
Rust
325 lines
10 KiB
Rust
//! 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<i16> {
|
|
(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<i16> = (-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);
|
|
}
|
|
}
|