use serde::{Deserialize, Serialize};
use crate::buffer::AudioBuffer;
use crate::dsp::{amplitude_to_db, db_to_amplitude};
#[must_use]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
#[serde(default)]
pub struct CompressorParams {
pub threshold_db: f32,
pub ratio: f32,
pub attack_ms: f32,
pub release_ms: f32,
pub makeup_gain_db: f32,
pub knee_db: f32,
pub mix: f32,
}
impl Default for CompressorParams {
fn default() -> Self {
Self {
threshold_db: -20.0,
ratio: 4.0,
attack_ms: 10.0,
release_ms: 100.0,
makeup_gain_db: 0.0,
knee_db: 0.0,
mix: 1.0,
}
}
}
impl CompressorParams {
pub fn new() -> Self {
Self::default()
}
#[inline]
pub fn with_threshold(mut self, db: f32) -> Self {
self.threshold_db = db;
self
}
#[inline]
pub fn with_ratio(mut self, ratio: f32) -> Self {
self.ratio = ratio;
self
}
#[inline]
pub fn with_attack(mut self, ms: f32) -> Self {
self.attack_ms = ms;
self
}
#[inline]
pub fn with_release(mut self, ms: f32) -> Self {
self.release_ms = ms;
self
}
#[inline]
pub fn with_makeup_gain(mut self, db: f32) -> Self {
self.makeup_gain_db = db;
self
}
#[inline]
pub fn with_knee(mut self, db: f32) -> Self {
self.knee_db = db;
self
}
#[inline]
pub fn with_mix(mut self, mix: f32) -> Self {
self.mix = mix;
self
}
pub fn validate(&self) -> Result<(), &'static str> {
if self.ratio < 1.0 {
return Err("ratio must be >= 1.0");
}
if self.attack_ms < 0.0 {
return Err("attack_ms must be >= 0.0");
}
if self.release_ms < 0.0 {
return Err("release_ms must be >= 0.0");
}
if self.knee_db < 0.0 {
return Err("knee_db must be >= 0.0");
}
Ok(())
}
}
#[must_use]
#[derive(Debug, Clone)]
pub struct Compressor {
params: CompressorParams,
envelope_db: f32,
gain_reduction_db: f32,
sample_rate: u32,
bypassed: bool,
}
impl Compressor {
pub fn new(params: CompressorParams, sample_rate: u32) -> crate::Result<Self> {
params
.validate()
.map_err(|reason| crate::NadaError::InvalidParameter {
name: "CompressorParams".into(),
value: String::new(),
reason: reason.into(),
})?;
tracing::debug!(
sample_rate,
threshold_db = params.threshold_db,
ratio = params.ratio,
"Compressor: created"
);
Ok(Self {
params,
envelope_db: -120.0,
gain_reduction_db: 0.0,
sample_rate,
bypassed: false,
})
}
#[inline]
pub fn process(&mut self, buf: &mut AudioBuffer) {
if self.params.ratio <= 1.0 {
return;
}
if self.bypassed {
return;
}
let ch = buf.channels as usize;
let attack_coeff = Self::time_constant(self.params.attack_ms, self.sample_rate);
let release_coeff = Self::time_constant(self.params.release_ms, self.sample_rate);
let makeup_lin = db_to_amplitude(self.params.makeup_gain_db);
let mix = self.params.mix.clamp(0.0, 1.0);
let dry = 1.0 - mix;
for frame in 0..buf.frames {
let mut peak = 0.0f32;
for c in 0..ch {
peak = peak.max(buf.samples[frame * ch + c].abs());
}
let input_db = amplitude_to_db(peak).max(-120.0);
let coeff = if input_db > self.envelope_db {
attack_coeff
} else {
release_coeff
};
self.envelope_db = coeff * self.envelope_db + (1.0 - coeff) * input_db;
let gain_db = self.compute_gain(self.envelope_db);
self.gain_reduction_db = gain_db;
if gain_db.is_finite() {
let gain_lin = db_to_amplitude(gain_db) * makeup_lin;
for c in 0..ch {
let idx = frame * ch + c;
let dry_sample = buf.samples[idx];
let wet_sample = dry_sample * gain_lin;
buf.samples[idx] = dry_sample * dry + wet_sample * mix;
if !buf.samples[idx].is_finite() {
buf.samples[idx] = 0.0;
}
}
}
}
}
fn compute_gain(&self, env_db: f32) -> f32 {
let slope = 1.0 / self.params.ratio - 1.0;
super::soft_knee_gain(env_db, self.params.threshold_db, self.params.knee_db, slope)
}
fn time_constant(time_ms: f32, sample_rate: u32) -> f32 {
abaco::dsp::time_constant(time_ms, sample_rate)
}
pub fn gain_reduction_db(&self) -> f32 {
self.gain_reduction_db
}
pub fn set_bypass(&mut self, bypassed: bool) {
self.bypassed = bypassed;
}
pub fn is_bypassed(&self) -> bool {
self.bypassed
}
pub fn set_params(&mut self, params: CompressorParams) -> crate::Result<()> {
params
.validate()
.map_err(|reason| crate::NadaError::InvalidParameter {
name: "CompressorParams".into(),
value: String::new(),
reason: reason.into(),
})?;
self.params = params;
Ok(())
}
pub fn set_sample_rate(&mut self, sample_rate: u32) {
tracing::debug!(sample_rate, "Compressor: sample rate updated");
self.sample_rate = sample_rate;
}
pub fn reset(&mut self) {
self.envelope_db = -120.0;
self.gain_reduction_db = 0.0;
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_sine(amplitude: f32, frames: usize) -> AudioBuffer {
let samples: Vec<f32> = (0..frames)
.map(|i| amplitude * (2.0 * std::f32::consts::PI * 440.0 * i as f32 / 44100.0).sin())
.collect();
AudioBuffer::from_interleaved(samples, 1, 44100).unwrap()
}
#[test]
fn ratio_one_no_compression() {
let params = CompressorParams {
ratio: 1.0,
..Default::default()
};
let mut comp = Compressor::new(params, 44100).unwrap();
let mut buf = make_sine(1.0, 4096);
let original = buf.samples.clone();
comp.process(&mut buf);
assert_eq!(buf.samples, original);
}
#[test]
fn below_threshold_unchanged() {
let params = CompressorParams {
threshold_db: 0.0, ratio: 10.0,
attack_ms: 0.01,
release_ms: 0.01,
makeup_gain_db: 0.0,
knee_db: 0.0,
..Default::default()
};
let mut comp = Compressor::new(params, 44100).unwrap();
let mut buf = make_sine(0.1, 4096);
let original_rms = buf.rms();
comp.process(&mut buf);
assert!(
(buf.rms() - original_rms).abs() < original_rms * 0.1,
"Below-threshold signal should be mostly unchanged"
);
}
#[test]
fn above_threshold_compressed() {
let params = CompressorParams {
threshold_db: -20.0,
ratio: 10.0,
attack_ms: 0.01,
release_ms: 0.01,
makeup_gain_db: 0.0,
knee_db: 0.0,
..Default::default()
};
let mut comp = Compressor::new(params, 44100).unwrap();
let mut buf = make_sine(1.0, 4096);
let original_rms = buf.rms();
comp.process(&mut buf);
assert!(
buf.rms() < original_rms * 0.95,
"Above-threshold signal should be compressed: rms={} vs original={}",
buf.rms(),
original_rms
);
}
#[test]
fn makeup_gain_boosts() {
let params = CompressorParams {
threshold_db: 0.0,
ratio: 4.0,
attack_ms: 0.01,
release_ms: 0.01,
makeup_gain_db: 12.0,
knee_db: 0.0,
..Default::default()
};
let mut comp = Compressor::new(params, 44100).unwrap();
let mut buf = make_sine(0.1, 4096);
let original_rms = buf.rms();
comp.process(&mut buf);
assert!(buf.rms() > original_rms * 2.0);
}
#[test]
fn soft_knee_smoother_than_hard() {
let params = CompressorParams {
threshold_db: -12.0,
ratio: 4.0,
attack_ms: 5.0,
release_ms: 50.0,
makeup_gain_db: 0.0,
knee_db: 12.0,
..Default::default()
};
let mut comp = Compressor::new(params, 44100).unwrap();
let mut buf = make_sine(1.0, 4096);
comp.process(&mut buf);
assert!(buf.samples.iter().all(|s| s.is_finite()));
assert!(buf.rms() > 0.0);
}
#[test]
fn reset_clears_state() {
let mut comp = Compressor::new(CompressorParams::default(), 44100).unwrap();
let mut buf = make_sine(1.0, 1024);
comp.process(&mut buf);
comp.reset();
assert!((comp.envelope_db - (-120.0)).abs() < f32::EPSILON);
assert!(comp.gain_reduction_db().abs() < f32::EPSILON);
}
}