#![forbid(unsafe_code)]
#![allow(clippy::cast_precision_loss)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum DetectionMode {
#[default]
Rms,
Peak,
}
#[derive(Debug, Clone)]
pub struct DuckerConfig {
pub sample_rate: f32,
pub threshold_db: f32,
pub depth_db: f32,
pub attack_ms: f32,
pub hold_ms: f32,
pub release_ms: f32,
pub rms_window_ms: f32,
pub detection: DetectionMode,
}
impl Default for DuckerConfig {
fn default() -> Self {
Self {
sample_rate: 48_000.0,
threshold_db: -20.0,
depth_db: 10.0,
attack_ms: 10.0,
hold_ms: 200.0,
release_ms: 500.0,
rms_window_ms: 30.0,
detection: DetectionMode::Rms,
}
}
}
#[inline]
fn db_to_linear(db: f32) -> f32 {
10.0_f32.powf(db / 20.0)
}
#[inline]
fn linear_to_db(lin: f32) -> f32 {
if lin <= 1e-12 {
return -240.0;
}
20.0 * lin.log10()
}
#[inline]
fn rc_coeff(tau_ms: f32, sample_rate: f32) -> f32 {
if tau_ms <= 0.0 || sample_rate <= 0.0 {
return 0.0;
}
let tau_samples = tau_ms * 0.001 * sample_rate;
(-1.0_f32 / tau_samples).exp()
}
struct RmsTracker {
buf: Vec<f32>, pos: usize,
sum_sq: f64,
}
impl RmsTracker {
fn new(window_samples: usize) -> Self {
let len = window_samples.max(1);
Self {
buf: vec![0.0; len],
pos: 0,
sum_sq: 0.0,
}
}
fn push(&mut self, sample: f32) -> f32 {
let sq = (sample as f64) * (sample as f64);
self.sum_sq -= self.buf[self.pos] as f64;
self.buf[self.pos] = sq as f32;
self.sum_sq += sq;
self.pos = (self.pos + 1) % self.buf.len();
let mean = (self.sum_sq / self.buf.len() as f64).max(0.0);
mean.sqrt() as f32
}
fn reset(&mut self) {
self.buf.fill(0.0);
self.pos = 0;
self.sum_sq = 0.0;
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DuckState {
Idle,
Attacking,
Hold,
Releasing,
}
pub struct Ducker {
config: DuckerConfig,
rms: RmsTracker,
gain: f32,
duck_gain: f32,
state: DuckState,
hold_counter: u64,
attack_coeff: f32,
release_coeff: f32,
hold_samples: u64,
threshold_lin: f32,
}
impl Ducker {
#[must_use]
pub fn new(config: DuckerConfig) -> Self {
let sr = config.sample_rate;
let window_samples = ((config.rms_window_ms * 0.001 * sr).round() as usize).max(1);
let attack_coeff = rc_coeff(config.attack_ms, sr);
let release_coeff = rc_coeff(config.release_ms, sr);
let hold_samples = (config.hold_ms * 0.001 * sr).round() as u64;
let threshold_lin = db_to_linear(config.threshold_db);
let duck_gain = db_to_linear(-config.depth_db.abs());
Self {
config,
rms: RmsTracker::new(window_samples),
gain: 1.0,
duck_gain,
state: DuckState::Idle,
hold_counter: 0,
attack_coeff,
release_coeff,
hold_samples,
threshold_lin,
}
}
pub fn update_config(&mut self, config: DuckerConfig) {
let sr = config.sample_rate;
let window_samples = ((config.rms_window_ms * 0.001 * sr).round() as usize).max(1);
self.attack_coeff = rc_coeff(config.attack_ms, sr);
self.release_coeff = rc_coeff(config.release_ms, sr);
self.hold_samples = (config.hold_ms * 0.001 * sr).round() as u64;
self.threshold_lin = db_to_linear(config.threshold_db);
self.duck_gain = db_to_linear(-config.depth_db.abs());
self.rms = RmsTracker::new(window_samples);
self.config = config;
}
pub fn reset(&mut self) {
self.rms.reset();
self.gain = 1.0;
self.state = DuckState::Idle;
self.hold_counter = 0;
}
#[must_use]
pub fn current_gain(&self) -> f32 {
self.gain
}
#[must_use]
pub fn current_gain_db(&self) -> f32 {
linear_to_db(self.gain)
}
#[must_use]
pub fn is_ducking(&self) -> bool {
self.state != DuckState::Idle
}
pub fn process_sample(&mut self, background: f32, sidechain: f32) -> f32 {
let level = match self.config.detection {
DetectionMode::Rms => self.rms.push(sidechain),
DetectionMode::Peak => {
let _ = self.rms.push(sidechain);
sidechain.abs()
}
};
match self.state {
DuckState::Idle => {
if level >= self.threshold_lin {
self.state = DuckState::Attacking;
}
}
DuckState::Attacking => {
if level < self.threshold_lin {
self.state = DuckState::Hold;
self.hold_counter = self.hold_samples;
}
}
DuckState::Hold => {
if level >= self.threshold_lin {
self.state = DuckState::Attacking;
self.hold_counter = 0;
} else if self.hold_counter == 0 {
self.state = DuckState::Releasing;
} else {
self.hold_counter -= 1;
}
}
DuckState::Releasing => {
if level >= self.threshold_lin {
self.state = DuckState::Attacking;
} else if (self.gain - 1.0).abs() < 1e-5 {
self.state = DuckState::Idle;
}
}
}
let target = match self.state {
DuckState::Idle => 1.0,
DuckState::Attacking | DuckState::Hold => self.duck_gain,
DuckState::Releasing => 1.0,
};
let coeff = match self.state {
DuckState::Attacking | DuckState::Hold => self.attack_coeff,
DuckState::Releasing | DuckState::Idle => self.release_coeff,
};
self.gain = coeff * self.gain + (1.0 - coeff) * target;
self.gain = self.gain.clamp(self.duck_gain, 1.0);
background * self.gain
}
#[must_use]
pub fn process_stereo(&mut self, background: &[f32], sidechain: &[f32]) -> Vec<f32> {
let n = background.len().min(sidechain.len());
(0..n)
.map(|i| self.process_sample(background[i], sidechain[i]))
.collect()
}
pub fn process_inplace(&mut self, background: &mut [f32], sidechain: &[f32]) {
let n = background.len().min(sidechain.len());
for i in 0..n {
background[i] = self.process_sample(background[i], sidechain[i]);
}
}
#[must_use]
pub fn process_stereo_interleaved(
&mut self,
background: &[f32],
sidechain: &[f32],
) -> Vec<f32> {
let n_frames = background.len().min(sidechain.len()) / 2;
let mut out = Vec::with_capacity(n_frames * 2);
for i in 0..n_frames {
let sc = (sidechain[i * 2].abs() + sidechain[i * 2 + 1].abs()) * 0.5;
let _gain = {
let dummy_bg = background[i * 2];
let ducked = self.process_sample(dummy_bg, sc);
ducked / dummy_bg.abs().max(1e-10) * dummy_bg.signum()
};
let g = self.gain; out.push(background[i * 2] * g);
out.push(background[i * 2 + 1] * g);
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
fn default_ducker() -> Ducker {
Ducker::new(DuckerConfig::default())
}
#[test]
fn test_no_ducking_when_sidechain_silent() {
let mut d = default_ducker();
let bg: Vec<f32> = vec![0.5; 2048];
let sc: Vec<f32> = vec![0.0; 2048];
let out = d.process_stereo(&bg, &sc);
let final_out = *out.last().expect("non-empty");
assert!(
(final_out - 0.5).abs() < 0.01,
"Silent SC should not duck; got {final_out}"
);
}
#[test]
fn test_ducking_reduces_level() {
let mut d = default_ducker();
let bg: Vec<f32> = vec![1.0; 4096];
let sc: Vec<f32> = vec![1.0; 4096]; let out = d.process_stereo(&bg, &sc);
let final_out = *out.last().expect("non-empty");
assert!(
final_out < 0.5,
"Loud SC should duck background; got {final_out}"
);
}
#[test]
fn test_is_ducking_flag() {
let mut d = default_ducker();
assert!(!d.is_ducking());
for _ in 0..100 {
d.process_sample(1.0, 1.0);
}
assert!(d.is_ducking(), "Should be ducking after loud SC");
}
#[test]
fn test_gain_in_range() {
let mut d = default_ducker();
let bg = vec![0.8_f32; 1024];
let sc = vec![0.9_f32; 1024];
let out = d.process_stereo(&bg, &sc);
for &s in &out {
assert!(s.is_finite(), "Output must be finite");
assert!(s >= -2.0 && s <= 2.0, "Output out of range: {s}");
}
}
#[test]
fn test_reset_clears_state() {
let mut d = default_ducker();
for _ in 0..500 {
d.process_sample(1.0, 1.0);
}
d.reset();
assert!(!d.is_ducking());
assert!((d.current_gain() - 1.0).abs() < 1e-5);
}
#[test]
fn test_current_gain_db_no_ducking() {
let d = Ducker::new(DuckerConfig::default());
assert!(d.current_gain_db().abs() < 0.1);
}
#[test]
fn test_process_inplace_matches_process_stereo() {
let mut d1 = default_ducker();
let mut d2 = default_ducker();
let bg = vec![0.6_f32; 256];
let sc = vec![0.4_f32; 256];
let out1 = d1.process_stereo(&bg, &sc);
let mut bg2 = bg.clone();
d2.process_inplace(&mut bg2, &sc);
for (a, b) in out1.iter().zip(bg2.iter()) {
assert!((a - b).abs() < 1e-6, "inplace vs stereo mismatch");
}
}
#[test]
fn test_process_stereo_shorter_length() {
let mut d = default_ducker();
let bg = vec![0.5_f32; 100];
let sc = vec![0.3_f32; 50];
let out = d.process_stereo(&bg, &sc);
assert_eq!(out.len(), 50);
}
#[test]
fn test_update_config() {
let mut d = default_ducker();
let new_cfg = DuckerConfig {
depth_db: 20.0,
..DuckerConfig::default()
};
d.update_config(new_cfg);
for _ in 0..2000 {
d.process_sample(1.0, 1.0);
}
assert!(d.current_gain() < 0.2, "20 dB duck should be deep");
}
#[test]
fn test_peak_detection_mode() {
let mut d = Ducker::new(DuckerConfig {
detection: DetectionMode::Peak,
..DuckerConfig::default()
});
for _ in 0..1000 {
d.process_sample(1.0, 1.0);
}
assert!(d.is_ducking());
}
#[test]
fn test_rms_tracker_push_zero() {
let mut t = RmsTracker::new(100);
let rms = t.push(0.0);
assert_eq!(rms, 0.0);
}
#[test]
fn test_rms_tracker_constant_signal() {
let mut t = RmsTracker::new(100);
let mut last = 0.0_f32;
for _ in 0..200 {
last = t.push(0.5);
}
assert!(
(last - 0.5).abs() < 0.01,
"RMS of 0.5 const = 0.5; got {last}"
);
}
#[test]
fn test_db_to_linear_zero_db() {
assert!((db_to_linear(0.0) - 1.0).abs() < 1e-6);
}
#[test]
fn test_db_to_linear_minus_20() {
let lin = db_to_linear(-20.0);
assert!((lin - 0.1).abs() < 1e-5);
}
}