#![allow(clippy::cast_precision_loss)]
use std::f32::consts::PI;
#[derive(Debug, Clone, PartialEq)]
pub struct BandConfig {
pub threshold_db: f32,
pub ratio: f32,
pub attack_ms: f32,
pub release_ms: f32,
pub knee_db: f32,
pub makeup_gain_db: f32,
}
impl Default for BandConfig {
fn default() -> Self {
Self {
threshold_db: -18.0,
ratio: 4.0,
attack_ms: 10.0,
release_ms: 100.0,
knee_db: 6.0,
makeup_gain_db: 0.0,
}
}
}
impl BandConfig {
#[must_use]
pub fn gentle() -> Self {
Self {
threshold_db: -12.0,
ratio: 2.0,
attack_ms: 20.0,
release_ms: 200.0,
knee_db: 6.0,
makeup_gain_db: 0.0,
}
}
#[must_use]
pub fn limiting() -> Self {
Self {
threshold_db: -6.0,
ratio: 20.0,
attack_ms: 1.0,
release_ms: 50.0,
knee_db: 0.0,
makeup_gain_db: 0.0,
}
}
pub fn validate(&self) -> Result<(), String> {
if self.ratio < 1.0 {
return Err(format!("ratio must be >= 1.0, got {}", self.ratio));
}
if self.attack_ms <= 0.0 {
return Err(format!("attack_ms must be > 0, got {}", self.attack_ms));
}
if self.release_ms <= 0.0 {
return Err(format!("release_ms must be > 0, got {}", self.release_ms));
}
if self.knee_db < 0.0 {
return Err(format!("knee_db must be >= 0, got {}", self.knee_db));
}
Ok(())
}
}
#[derive(Debug, Clone)]
struct Biquad {
b0: f32,
b1: f32,
b2: f32,
a1: f32,
a2: f32,
s1: f32,
s2: f32,
}
impl Biquad {
fn process(&mut self, x: f32) -> f32 {
let y = self.b0 * x + self.s1;
self.s1 = self.b1 * x - self.a1 * y + self.s2;
self.s2 = self.b2 * x - self.a2 * y;
y
}
fn reset(&mut self) {
self.s1 = 0.0;
self.s2 = 0.0;
}
fn lr_lowpass(freq_hz: f32, sample_rate: f32) -> Self {
let w0 = 2.0 * PI * freq_hz / sample_rate;
let cos_w0 = w0.cos();
let sin_w0 = w0.sin();
let q = 0.5_f32.sqrt(); let alpha = sin_w0 / (2.0 * q);
let a0 = 1.0 + alpha;
Self {
b0: ((1.0 - cos_w0) / 2.0) / a0,
b1: (1.0 - cos_w0) / a0,
b2: ((1.0 - cos_w0) / 2.0) / a0,
a1: (-2.0 * cos_w0) / a0,
a2: (1.0 - alpha) / a0,
s1: 0.0,
s2: 0.0,
}
}
fn lr_highpass(freq_hz: f32, sample_rate: f32) -> Self {
let w0 = 2.0 * PI * freq_hz / sample_rate;
let cos_w0 = w0.cos();
let sin_w0 = w0.sin();
let q = 0.5_f32.sqrt();
let alpha = sin_w0 / (2.0 * q);
let a0 = 1.0 + alpha;
Self {
b0: ((1.0 + cos_w0) / 2.0) / a0,
b1: -(1.0 + cos_w0) / a0,
b2: ((1.0 + cos_w0) / 2.0) / a0,
a1: (-2.0 * cos_w0) / a0,
a2: (1.0 - alpha) / a0,
s1: 0.0,
s2: 0.0,
}
}
}
#[derive(Debug, Clone)]
struct BandCompressor {
config: BandConfig,
envelope_db: f32,
attack_coeff: f32,
release_coeff: f32,
makeup_linear: f32,
}
impl BandCompressor {
fn new(config: BandConfig, sample_rate: f32) -> Self {
let attack_coeff = compute_time_coeff(config.attack_ms, sample_rate);
let release_coeff = compute_time_coeff(config.release_ms, sample_rate);
let makeup_linear = db_to_linear(config.makeup_gain_db);
Self {
config,
envelope_db: -144.0,
attack_coeff,
release_coeff,
makeup_linear,
}
}
fn process_sample(&mut self, x: f32) -> f32 {
let input_db = linear_to_db(x.abs());
if input_db > self.envelope_db {
self.envelope_db =
self.attack_coeff * self.envelope_db + (1.0 - self.attack_coeff) * input_db;
} else {
self.envelope_db =
self.release_coeff * self.envelope_db + (1.0 - self.release_coeff) * input_db;
}
let gain_db = self.compute_gain(self.envelope_db);
x * db_to_linear(gain_db) * self.makeup_linear
}
fn compute_gain(&self, level_db: f32) -> f32 {
let threshold = self.config.threshold_db;
let ratio = self.config.ratio;
let knee = self.config.knee_db;
let excess = level_db - threshold;
if knee > 0.0 {
let half_knee = knee / 2.0;
if excess < -half_knee {
0.0
} else if excess < half_knee {
let t = (excess + half_knee) / knee;
let slope = (1.0 / ratio - 1.0) * t * t / 2.0;
slope * knee / 2.0 } else {
(1.0 / ratio - 1.0) * excess + (1.0 / ratio - 1.0) * half_knee / 2.0
}
} else if excess > 0.0 {
(1.0 / ratio - 1.0) * excess
} else {
0.0
}
}
fn reset(&mut self) {
self.envelope_db = -144.0;
}
#[allow(dead_code)]
fn update_sample_rate(&mut self, sample_rate: f32) {
self.attack_coeff = compute_time_coeff(self.config.attack_ms, sample_rate);
self.release_coeff = compute_time_coeff(self.config.release_ms, sample_rate);
}
}
pub struct MultibandCompressor {
crossover_low_hz: f32,
crossover_high_hz: f32,
lp1: Biquad,
hp1: Biquad,
lp2: Biquad,
hp2: Biquad,
comp_low: BandCompressor,
comp_mid: BandCompressor,
comp_high: BandCompressor,
#[allow(dead_code)]
sample_rate: f32,
}
impl MultibandCompressor {
#[must_use]
pub fn new(sample_rate: f32) -> Self {
let crossover_low = 250.0f32;
let crossover_high = 4_000.0f32;
Self::new_with_bands(
BandConfig::default(),
BandConfig::default(),
BandConfig::default(),
crossover_low,
crossover_high,
sample_rate,
)
}
#[must_use]
pub fn new_with_bands(
low: BandConfig,
mid: BandConfig,
high: BandConfig,
crossover_low_hz: f32,
crossover_high_hz: f32,
sample_rate: f32,
) -> Self {
let xover_low = crossover_low_hz.clamp(20.0, sample_rate * 0.45);
let xover_high = crossover_high_hz.clamp(xover_low + 1.0, sample_rate * 0.49);
Self {
crossover_low_hz: xover_low,
crossover_high_hz: xover_high,
lp1: Biquad::lr_lowpass(xover_low, sample_rate),
hp1: Biquad::lr_highpass(xover_low, sample_rate),
lp2: Biquad::lr_lowpass(xover_high, sample_rate),
hp2: Biquad::lr_highpass(xover_high, sample_rate),
comp_low: BandCompressor::new(low, sample_rate),
comp_mid: BandCompressor::new(mid, sample_rate),
comp_high: BandCompressor::new(high, sample_rate),
sample_rate,
}
}
#[must_use]
pub fn crossover_low_hz(&self) -> f32 {
self.crossover_low_hz
}
#[must_use]
pub fn crossover_high_hz(&self) -> f32 {
self.crossover_high_hz
}
pub fn process_sample(&mut self, input: f32) -> f32 {
let low = self.lp1.process(input);
let mid_hp = self.hp1.process(input);
let mid = self.lp2.process(mid_hp);
let high = self.hp2.process(mid_hp);
let low_out = self.comp_low.process_sample(low);
let mid_out = self.comp_mid.process_sample(mid);
let high_out = self.comp_high.process_sample(high);
low_out + mid_out + high_out
}
pub fn process(&mut self, buffer: &mut [f32]) {
for sample in buffer.iter_mut() {
*sample = self.process_sample(*sample);
}
}
pub fn reset(&mut self) {
self.lp1.reset();
self.hp1.reset();
self.lp2.reset();
self.hp2.reset();
self.comp_low.reset();
self.comp_mid.reset();
self.comp_high.reset();
}
#[must_use]
pub fn low_config(&self) -> &BandConfig {
&self.comp_low.config
}
#[must_use]
pub fn mid_config(&self) -> &BandConfig {
&self.comp_mid.config
}
#[must_use]
pub fn high_config(&self) -> &BandConfig {
&self.comp_high.config
}
}
fn compute_time_coeff(time_ms: f32, sample_rate: f32) -> f32 {
if time_ms <= 0.0 || sample_rate <= 0.0 {
return 0.0;
}
let time_samples = time_ms * 0.001 * sample_rate;
(-1.0 / time_samples).exp()
}
fn linear_to_db(x: f32) -> f32 {
if x < 1e-7 {
-144.0
} else {
20.0 * x.log10()
}
}
fn db_to_linear(db: f32) -> f32 {
10.0_f32.powf(db / 20.0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_band_config_default() {
let c = BandConfig::default();
assert!(c.validate().is_ok());
assert!(c.ratio >= 1.0);
assert!(c.attack_ms > 0.0);
assert!(c.release_ms > 0.0);
}
#[test]
fn test_band_config_gentle() {
let c = BandConfig::gentle();
assert!(c.validate().is_ok());
}
#[test]
fn test_band_config_limiting() {
let c = BandConfig::limiting();
assert!(c.validate().is_ok());
assert!(c.ratio >= 10.0);
}
#[test]
fn test_band_config_validate_bad_ratio() {
let c = BandConfig {
ratio: 0.5,
..Default::default()
};
assert!(c.validate().is_err());
}
#[test]
fn test_band_config_validate_zero_attack() {
let c = BandConfig {
attack_ms: 0.0,
..Default::default()
};
assert!(c.validate().is_err());
}
#[test]
fn test_band_config_validate_zero_release() {
let c = BandConfig {
release_ms: 0.0,
..Default::default()
};
assert!(c.validate().is_err());
}
#[test]
fn test_band_config_validate_negative_knee() {
let c = BandConfig {
knee_db: -1.0,
..Default::default()
};
assert!(c.validate().is_err());
}
#[test]
fn test_new_default_crossovers() {
let comp = MultibandCompressor::new(48_000.0);
assert!((comp.crossover_low_hz() - 250.0).abs() < 1.0);
assert!((comp.crossover_high_hz() - 4_000.0).abs() < 1.0);
}
#[test]
fn test_new_with_bands_custom_crossovers() {
let comp = MultibandCompressor::new_with_bands(
BandConfig::default(),
BandConfig::default(),
BandConfig::default(),
300.0,
3_000.0,
48_000.0,
);
assert!((comp.crossover_low_hz() - 300.0).abs() < 1.0);
assert!((comp.crossover_high_hz() - 3_000.0).abs() < 1.0);
}
#[test]
fn test_process_sample_finite() {
let mut comp = MultibandCompressor::new(48_000.0);
for i in 0..1024 {
let s = (i as f32 * 0.02).sin() * 0.4;
let out = comp.process_sample(s);
assert!(out.is_finite(), "non-finite output at sample {i}");
}
}
#[test]
fn test_process_silent_input() {
let mut comp = MultibandCompressor::new(48_000.0);
let out = comp.process_sample(0.0);
assert!(out.abs() < 1e-6);
}
#[test]
fn test_process_buffer_length_preserved() {
let mut comp = MultibandCompressor::new(48_000.0);
let mut buf = vec![0.2f32; 512];
comp.process(&mut buf);
assert_eq!(buf.len(), 512);
}
#[test]
fn test_process_buffer_all_finite() {
let mut comp = MultibandCompressor::new(48_000.0);
let mut buf: Vec<f32> = (0..1024).map(|i| (i as f32 * 0.05).sin() * 0.5).collect();
comp.process(&mut buf);
for &v in &buf {
assert!(v.is_finite());
}
}
#[test]
fn test_reset_clears_state() {
let mut comp = MultibandCompressor::new(48_000.0);
let mut buf = vec![0.8f32; 512];
comp.process(&mut buf);
comp.reset();
let mut comp2 = MultibandCompressor::new(48_000.0);
let input = 0.1f32;
let out1 = comp.process_sample(input);
let out2 = comp2.process_sample(input);
assert!((out1 - out2).abs() < 1e-4);
}
#[test]
fn test_compression_reduces_loud_signal() {
let low = BandConfig {
threshold_db: -30.0,
ratio: 10.0,
attack_ms: 1.0,
release_ms: 50.0,
knee_db: 0.0,
makeup_gain_db: 0.0,
};
let mut comp = MultibandCompressor::new_with_bands(
low,
BandConfig::default(),
BandConfig::default(),
200.0,
5_000.0,
48_000.0,
);
let input = 0.9f32;
let mut outputs = Vec::new();
for _ in 0..500 {
outputs.push(comp.process_sample(input));
}
let last = *outputs.last().unwrap_or(&0.0);
assert!(
last.abs() <= input.abs() + 0.05,
"Compression did not reduce level: {last}"
);
}
#[test]
fn test_makeup_gain_increases_output() {
let band_no_makeup = BandConfig {
makeup_gain_db: 0.0,
threshold_db: -6.0,
ratio: 4.0,
attack_ms: 1.0,
release_ms: 50.0,
knee_db: 0.0,
};
let band_with_makeup = BandConfig {
makeup_gain_db: 6.0,
..band_no_makeup.clone()
};
let mut comp1 = MultibandCompressor::new_with_bands(
band_no_makeup,
BandConfig::default(),
BandConfig::default(),
250.0,
4_000.0,
48_000.0,
);
let mut comp2 = MultibandCompressor::new_with_bands(
band_with_makeup,
BandConfig::default(),
BandConfig::default(),
250.0,
4_000.0,
48_000.0,
);
let input = 0.1f32; for _ in 0..100 {
comp1.process_sample(input);
comp2.process_sample(input);
}
let out1 = comp1.process_sample(input);
let out2 = comp2.process_sample(input);
assert!(
out2.abs() >= out1.abs(),
"Makeup gain should increase output level"
);
}
#[test]
fn test_db_to_linear_roundtrip() {
let db = -12.0f32;
let linear = db_to_linear(db);
let back = linear_to_db(linear);
assert!((back - db).abs() < 0.01);
}
#[test]
fn test_linear_to_db_zero() {
let db = linear_to_db(0.0);
assert!(db <= -100.0, "Zero amplitude should give very low dB");
}
#[test]
fn test_compute_time_coeff_range() {
let coeff = compute_time_coeff(10.0, 48_000.0);
assert!(coeff > 0.0 && coeff < 1.0);
}
}