use crate::stage::InPlaceDsp;
fn db_to_linear(db: f32) -> f32 {
10.0_f32.powf(db / 20.0)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LevelMode {
Agc,
}
pub(crate) struct LevelControl {
mode: LevelMode,
target_linear: f32,
noise_floor_linear: f32,
max_gain_linear: f32,
min_gain_linear: f32,
level_coeff: f32,
rise_step: f32,
attack_coeff: f32,
warmup_coeff: f32,
warmup_remaining: u64,
hangover_samples: u64,
hangover_remaining: u64,
level_sq: f32,
sample_count: u64,
gain: f32,
}
impl LevelControl {
const LEVEL_TC_SECS: f32 = 0.4;
const RISE_DB_PER_SEC: f32 = 6.0;
const ATTACK_TC_SECS: f32 = 0.010;
const WARMUP_TC_SECS: f32 = 0.020;
const WARMUP_SECS: f32 = 0.1;
const HANGOVER_SECS: f32 = 0.2;
const NOISE_FLOOR_DBFS: f32 = -50.0;
const MAX_GAIN_DB: f32 = 30.0;
pub(crate) fn agc(target_db: i8, sample_rate: u32) -> Self {
let sr = sample_rate as f32;
let coeff = |tc: f32| 1.0 - (-1.0 / (tc * sr)).exp();
Self {
mode: LevelMode::Agc,
target_linear: db_to_linear(target_db as f32),
noise_floor_linear: db_to_linear(Self::NOISE_FLOOR_DBFS),
max_gain_linear: db_to_linear(Self::MAX_GAIN_DB),
min_gain_linear: db_to_linear(-Self::MAX_GAIN_DB),
level_coeff: coeff(Self::LEVEL_TC_SECS),
rise_step: db_to_linear(Self::RISE_DB_PER_SEC / sr),
attack_coeff: coeff(Self::ATTACK_TC_SECS),
warmup_coeff: coeff(Self::WARMUP_TC_SECS),
warmup_remaining: (Self::WARMUP_SECS * sr) as u64,
hangover_samples: (Self::HANGOVER_SECS * sr) as u64,
hangover_remaining: 0,
level_sq: 0.0,
sample_count: 0,
gain: 1.0,
}
}
fn weigh(&self, sample: f32) -> f32 {
match self.mode {
LevelMode::Agc => sample,
}
}
}
impl InPlaceDsp for LevelControl {
fn process_in_place(&mut self, samples: &mut [f32]) {
for sample in samples.iter_mut() {
let x = *sample;
*sample = x * self.gain;
let weighted = self.weigh(x);
let coeff = self.level_coeff.max(1.0 / (self.sample_count + 1) as f32);
self.level_sq += coeff * (weighted * weighted - self.level_sq);
self.sample_count = self.sample_count.saturating_add(1);
let level = self.level_sq.sqrt();
let present = if level > self.noise_floor_linear {
self.hangover_remaining = self.hangover_samples;
true
} else if self.hangover_remaining > 0 {
self.hangover_remaining -= 1;
true
} else {
false
};
if present {
let desired =
(self.target_linear / level).clamp(self.min_gain_linear, self.max_gain_linear);
if desired > self.gain {
if self.warmup_remaining > 0 {
self.gain += (desired - self.gain) * self.warmup_coeff;
} else {
self.gain = (self.gain * self.rise_step).min(desired);
}
} else {
self.gain += (desired - self.gain) * self.attack_coeff;
}
if self.warmup_remaining > 0 {
self.warmup_remaining -= 1;
}
}
}
}
}
pub(crate) struct Limiter {
ceiling_linear: f32,
gain: f32,
attack_coeff: f32,
release_coeff: f32,
}
impl Limiter {
const ATTACK_TC_SECS: f32 = 0.0005;
const RELEASE_TC_SECS: f32 = 0.050;
pub(crate) fn new(ceiling_db: f32, sample_rate: u32) -> Self {
let sr = sample_rate as f32;
let coeff = |tc: f32| 1.0 - (-1.0 / (tc * sr)).exp();
Self {
ceiling_linear: db_to_linear(ceiling_db),
gain: 1.0,
attack_coeff: coeff(Self::ATTACK_TC_SECS),
release_coeff: coeff(Self::RELEASE_TC_SECS),
}
}
}
impl InPlaceDsp for Limiter {
fn process_in_place(&mut self, samples: &mut [f32]) {
for sample in samples.iter_mut() {
let x = *sample;
let mag = x.abs();
let desired = if mag > self.ceiling_linear {
self.ceiling_linear / mag
} else {
1.0
};
let coeff = if desired < self.gain {
self.attack_coeff
} else {
self.release_coeff
};
self.gain += (desired - self.gain) * coeff;
let y = x * self.gain;
*sample = if y.is_nan() {
0.0
} else {
y.clamp(-self.ceiling_linear, self.ceiling_linear)
};
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const SR: u32 = 16_000;
const TARGET_DB: i8 = -18;
fn rms(samples: &[f32]) -> f32 {
if samples.is_empty() {
return 0.0;
}
(samples.iter().map(|s| s * s).sum::<f32>() / samples.len() as f32).sqrt()
}
fn tone(amp: f32, freq_hz: f32, seconds: f32) -> Vec<f32> {
let n = (seconds * SR as f32) as usize;
(0..n)
.map(|i| amp * (2.0 * std::f32::consts::PI * freq_hz * i as f32 / SR as f32).sin())
.collect()
}
fn amp_for_dbfs(db: f32) -> f32 {
db_to_linear(db) * std::f32::consts::SQRT_2
}
fn run(lc: &mut LevelControl, block: &[f32]) -> Vec<f32> {
let mut buf = block.to_vec();
lc.process_in_place(&mut buf);
buf
}
#[test]
fn cold_start_first_sample_is_unity() {
for amp in [0.01_f32, 0.2, 0.9] {
let mut lc = LevelControl::agc(TARGET_DB, SR);
let input = tone(amp, 220.0, 0.05);
let out = run(&mut lc, &input);
assert_eq!(
out[0], input[0],
"the first sample passes at unity (amp {amp}): {} vs {}",
out[0], input[0]
);
}
}
#[test]
fn cold_start_converges_to_target_within_tens_of_ms() {
let mut lc = LevelControl::agc(TARGET_DB, SR);
let input = tone(amp_for_dbfs(-38.0), 220.0, 0.2);
let target = db_to_linear(TARGET_DB as f32);
let block = (0.02 * SR as f32) as usize;
let mut levels = Vec::new();
for chunk in input.chunks(block) {
levels.push(rms(&run(&mut lc, chunk)));
}
let early = levels[4];
assert!(
early > target * 0.7 && early < target * 1.4,
"output {early} should be near target {target} within ~100 ms"
);
let settled = *levels.last().unwrap();
assert!(
settled > target * 0.8 && settled < target * 1.25,
"settled output {settled} should track target {target}"
);
}
#[test]
fn cold_start_after_silence_converges_fast() {
let mut lc = LevelControl::agc(TARGET_DB, SR);
let target = db_to_linear(TARGET_DB as f32);
let block = (0.02 * SR as f32) as usize;
let _ = run(&mut lc, &vec![0.0_f32; (0.15 * SR as f32) as usize]);
let signal = tone(amp_for_dbfs(-38.0), 220.0, 1.0);
let levels: Vec<f32> = signal
.chunks(block)
.map(|c| rms(&run(&mut lc, c)))
.collect();
assert!(
levels[2] > target * 0.7,
"first signal after opening silence converges fast (got {} at ~60 ms, target {target}); \
the warm-up must be spent on signal, not on leading silence",
levels[2]
);
let tail: f32 = levels[levels.len() - 5..].iter().sum::<f32>() / 5.0;
assert!(
tail > target * 0.8 && tail < target * 1.25,
"the level settles at target after the silent opening (tail {tail}, target {target})"
);
}
#[test]
fn cold_start_silent_opening_is_not_boosted() {
let mut lc = LevelControl::agc(TARGET_DB, SR);
let silence = vec![0.0_f32; (0.3 * SR as f32) as usize];
let out = run(&mut lc, &silence);
assert!(
out.iter().all(|&s| s == 0.0),
"silence stays silent: the gate holds the rise, no boost"
);
let mut lc = LevelControl::agc(TARGET_DB, SR);
let noise = tone(amp_for_dbfs(-60.0), 300.0, 0.3);
let out = run(&mut lc, &noise);
let in_rms = rms(&noise);
let out_rms = rms(&out);
assert!(
out_rms < in_rms * 2.0,
"sub-noise-floor input is not boosted toward target: in {in_rms}, out {out_rms}"
);
}
#[test]
fn cold_start_hot_opening_does_not_overshoot() {
let mut lc = LevelControl::agc(TARGET_DB, SR);
let input = tone(0.9, 220.0, 0.3);
let out = run(&mut lc, &input);
assert!(
out.iter().all(|&s| s.abs() <= 1.0),
"no output sample clips full scale on a hot opening"
);
assert!(
out.iter().all(|&s| s.abs() <= 0.9 + 1e-6),
"a hot input is never boosted above its own peak"
);
let block = (0.02 * SR as f32) as usize;
let last: Vec<f32> = input.chunks(block).map(|c| rms(&run(&mut lc, c))).collect();
let _ = last; let settled = rms(&out[out.len() - block..]);
assert!(
settled < rms(&input[..block]),
"the hot input is attenuated toward target (settled {settled})"
);
}
#[test]
fn loud_onset_pulls_gain_down_even_while_rise_gated() {
let mut lc = LevelControl::agc(TARGET_DB, SR);
let _ = run(&mut lc, &vec![0.0_f32; (0.1 * SR as f32) as usize]);
let loud = tone(0.8, 220.0, 0.2);
let out = run(&mut lc, &loud);
let block = (0.02 * SR as f32) as usize;
let onset = rms(&out[..block]);
let after = rms(&out[out.len() - block..]);
assert!(
after < onset,
"the loud onset is pulled down (onset {onset} -> settled {after})"
);
let target = db_to_linear(TARGET_DB as f32);
assert!(
after < target * 2.0,
"the level is brought down toward target ({after} vs target {target})"
);
}
#[test]
fn steady_state_converges_and_holds() {
let target = db_to_linear(TARGET_DB as f32);
let block = (0.02 * SR as f32) as usize;
let mut lc = LevelControl::agc(TARGET_DB, SR);
let quiet = tone(amp_for_dbfs(-30.0), 220.0, 1.0);
let levels: Vec<f32> = quiet.chunks(block).map(|c| rms(&run(&mut lc, c))).collect();
let tail = &levels[levels.len() - 5..];
for &l in tail {
assert!(
l > target * 0.8 && l < target * 1.25,
"quiet input settles near target (got {l}, target {target})"
);
}
let tail_min = tail.iter().cloned().fold(f32::INFINITY, f32::min);
let tail_max = tail.iter().cloned().fold(0.0_f32, f32::max);
assert!(
tail_max / tail_min < 1.2,
"the settled level holds steady (min {tail_min}, max {tail_max})"
);
let mut lc = LevelControl::agc(TARGET_DB, SR);
let loud = tone(amp_for_dbfs(-6.0), 220.0, 1.0);
let levels: Vec<f32> = loud.chunks(block).map(|c| rms(&run(&mut lc, c))).collect();
let settled = *levels.last().unwrap();
assert!(
settled > target * 0.8 && settled < target * 1.25,
"loud input is pulled down to target (got {settled}, target {target})"
);
}
#[test]
fn state_carries_across_blocks() {
let input = tone(amp_for_dbfs(-30.0), 180.0, 0.5);
let one_shot = {
let mut lc = LevelControl::agc(TARGET_DB, SR);
run(&mut lc, &input)
};
let mut lc = LevelControl::agc(TARGET_DB, SR);
let mut chunked = Vec::with_capacity(input.len());
for chunk in [
&input[..1000],
&input[1000..1001],
&input[1001..4321],
&input[4321..],
] {
chunked.extend(run(&mut lc, chunk));
}
assert_eq!(
chunked, one_shot,
"per-sample control: chunked processing equals one-shot, bit for bit"
);
}
#[test]
fn empty_block_is_a_noop() {
let mut lc = LevelControl::agc(TARGET_DB, SR);
let out = run(&mut lc, &[]);
assert!(out.is_empty(), "an empty block yields no output");
let input = tone(0.3, 220.0, 0.05);
let out = run(&mut lc, &input);
assert_eq!(out[0], input[0], "the empty call did not advance state");
}
fn run_lim(lim: &mut Limiter, block: &[f32]) -> Vec<f32> {
let mut buf = block.to_vec();
lim.process_in_place(&mut buf);
buf
}
#[test]
fn limiter_absolute_ceiling_holds_for_instantaneous_transients() {
for ceiling_db in [-1.0_f32, -3.0, 0.0] {
let ceiling = db_to_linear(ceiling_db);
let mut spike_first = vec![0.0_f32; 4_000];
spike_first[0] = 1.0;
spike_first[1] = -1.0;
let mut over = tone(0.1, 220.0, 0.25);
for (i, s) in over.iter_mut().enumerate() {
if i % 500 == 0 {
*s = if i % 1000 == 0 { 5.0 } else { -5.0 };
}
}
let step = vec![1.0_f32; 4_000];
let burst = tone(0.95, 1_000.0, 0.25);
let mut glitch = tone(0.2, 220.0, 0.1);
glitch[0] = f32::NAN;
glitch[10] = f32::INFINITY;
glitch[20] = f32::NEG_INFINITY;
glitch[30] = f32::NAN;
for (label, input) in [
("first-sample spike", spike_first),
("over-full-scale spikes", over),
("full-scale step", step),
("sustained burst", burst),
("non-finite glitch frame", glitch),
] {
let mut lim = Limiter::new(ceiling_db, SR);
let out = run_lim(&mut lim, &input);
assert!(
out.iter().all(|s| s.is_finite()),
"ceiling {ceiling_db} dBFS ({ceiling}): {label} produced a non-finite output sample"
);
let worst = out.iter().cloned().fold(0.0_f32, |m, s| m.max(s.abs()));
assert!(
worst <= ceiling,
"ceiling {ceiling_db} dBFS ({ceiling}): {label} produced {worst} > ceiling, \
the hard-ceiling backstop must hold absolutely"
);
}
}
}
#[test]
fn limiter_shapes_loud_signal_gracefully() {
let ceiling_db = -1.0_f32;
let ceiling = db_to_linear(ceiling_db);
let mut lim = Limiter::new(ceiling_db, SR);
let input = tone(0.95, 1_000.0, 0.3);
let out = run_lim(&mut lim, &input);
let win = (0.05 * SR as f32) as usize;
let start = out.len() - win;
let settled_out = &out[start..];
let settled_in = &input[start..];
let peak = settled_out
.iter()
.cloned()
.fold(0.0_f32, |m, s| m.max(s.abs()));
assert!(
peak <= ceiling,
"settled peak {peak} stays at/below the ceiling {ceiling}"
);
assert!(
peak > ceiling * 0.9,
"the loud signal is pulled UP TO the ceiling, not far below ({peak})"
);
let idx = settled_in
.iter()
.position(|&x| x.abs() > 0.2 && x.abs() < ceiling * 0.5)
.expect("a clearly sub-ceiling sample exists in a 0.95 sine");
let naive_clamp = settled_in[idx].clamp(-ceiling, ceiling);
assert_eq!(
naive_clamp, settled_in[idx],
"a naive clamp leaves a sub-ceiling sample unchanged"
);
assert!(
(settled_out[idx] - settled_in[idx]).abs() > 1e-4,
"the limiter attenuates a sub-ceiling sample ({} vs input {}), so it is shaping, \
not clamping",
settled_out[idx],
settled_in[idx]
);
}
#[test]
fn limiter_transparent_below_ceiling() {
let mut lim = Limiter::new(-1.0, SR);
let input = tone(0.5, 220.0, 0.2);
let out = run_lim(&mut lim, &input);
assert_eq!(
out, input,
"a below-ceiling signal is delivered untouched, bit for bit"
);
}
#[test]
fn limiter_releases_after_transient() {
let ceiling_db = -1.0_f32;
let mut lim = Limiter::new(ceiling_db, SR);
let loud = tone(0.95, 1_000.0, 0.06);
let _ = run_lim(&mut lim, &loud);
let quiet = tone(0.3, 220.0, 0.4);
let out = run_lim(&mut lim, &quiet);
let win = (0.05 * SR as f32) as usize;
let in_rms = rms(&quiet[quiet.len() - win..]);
let out_rms = rms(&out[out.len() - win..]);
assert!(
out_rms > in_rms * 0.95,
"the quiet passage recovers to ~unity after the loud transient \
(in {in_rms}, out {out_rms})"
);
}
#[test]
fn limiter_state_carries_across_blocks() {
let input = tone(0.95, 1_000.0, 0.3);
let one_shot = {
let mut lim = Limiter::new(-1.0, SR);
run_lim(&mut lim, &input)
};
let mut lim = Limiter::new(-1.0, SR);
let mut chunked = Vec::with_capacity(input.len());
for chunk in [
&input[..1000],
&input[1000..1001],
&input[1001..4321],
&input[4321..],
] {
chunked.extend(run_lim(&mut lim, chunk));
}
assert_eq!(
chunked, one_shot,
"per-sample control: chunked processing equals one-shot, bit for bit"
);
}
}