#![allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)]
use std::collections::VecDeque;
#[derive(Debug, Clone)]
pub struct BroadcastLimiterConfig {
pub threshold_db: f32,
pub lookahead_ms: f32,
pub release_ms: f32,
pub sample_rate: u32,
}
impl BroadcastLimiterConfig {
#[must_use]
pub fn broadcast_standard(sample_rate: u32) -> Self {
Self {
threshold_db: -1.0,
lookahead_ms: 5.0,
release_ms: 200.0,
sample_rate,
}
}
#[must_use]
pub fn streaming(sample_rate: u32) -> Self {
Self {
threshold_db: -2.0,
lookahead_ms: 3.0,
release_ms: 100.0,
sample_rate,
}
}
pub fn validate(&self) -> Result<(), String> {
if self.threshold_db > 0.0 {
return Err(format!(
"threshold_db must be ≤ 0.0 dBFS, got {:.2}",
self.threshold_db
));
}
if !(0.0..=100.0).contains(&self.lookahead_ms) {
return Err(format!(
"lookahead_ms must be in [0, 100], got {:.2}",
self.lookahead_ms
));
}
if self.release_ms <= 0.0 {
return Err(format!(
"release_ms must be > 0.0, got {:.2}",
self.release_ms
));
}
if self.sample_rate == 0 {
return Err("sample_rate must be > 0".to_string());
}
Ok(())
}
}
pub struct BroadcastLimiter {
config: BroadcastLimiterConfig,
delay_buffer: VecDeque<f32>,
gain_buffer: VecDeque<f32>,
current_gain: f32,
lookahead_samples: usize,
release_coeff: f32,
threshold_linear: f32,
}
impl BroadcastLimiter {
#[must_use]
pub fn new(config: BroadcastLimiterConfig) -> Self {
let sr = config.sample_rate as f32;
let lookahead_samples =
((config.lookahead_ms * sr / 1000.0) as usize).max(1);
let release_samples = config.release_ms * sr / 1000.0;
let release_coeff = if release_samples > 0.0 {
(-1.0_f32 / release_samples).exp()
} else {
0.0
};
let threshold_linear = 10.0_f32.powf(config.threshold_db / 20.0);
let delay_buffer = VecDeque::from(vec![0.0_f32; lookahead_samples]);
let gain_buffer = VecDeque::from(vec![1.0_f32; lookahead_samples]);
Self {
config,
delay_buffer,
gain_buffer,
current_gain: 1.0,
lookahead_samples,
release_coeff,
threshold_linear,
}
}
pub fn process_sample(&mut self, input: f32) -> (f32, f32) {
let abs_in = input.abs().max(1e-10);
let gain_needed = if abs_in > self.threshold_linear {
(self.threshold_linear / abs_in).min(1.0)
} else {
1.0
};
self.current_gain = self.current_gain.min(gain_needed);
self.current_gain +=
(1.0 - self.current_gain) * (1.0 - self.release_coeff);
self.current_gain = self.current_gain.min(1.0);
self.gain_buffer.push_back(self.current_gain);
let output_gain = self.gain_buffer.pop_front().unwrap_or(1.0);
self.delay_buffer.push_back(input);
let delayed = self.delay_buffer.pop_front().unwrap_or(0.0);
let output = delayed * output_gain;
let gain_db = 20.0 * output_gain.max(f32::EPSILON).log10();
(output, gain_db)
}
#[must_use]
pub fn process_buffer(&mut self, input: &[f32]) -> Vec<f32> {
input.iter().map(|&s| self.process_sample(s).0).collect()
}
#[must_use]
pub fn gain_reduction_db(&self) -> f32 {
20.0 * self.current_gain.max(f32::EPSILON).log10()
}
#[must_use]
pub fn latency_samples(&self) -> usize {
self.lookahead_samples
}
pub fn reset(&mut self) {
for v in self.delay_buffer.iter_mut() {
*v = 0.0;
}
for v in self.gain_buffer.iter_mut() {
*v = 1.0;
}
self.current_gain = 1.0;
}
#[must_use]
pub fn config(&self) -> &BroadcastLimiterConfig {
&self.config
}
}
#[cfg(test)]
mod tests {
use super::*;
const SR: u32 = 48_000;
fn make_sine(freq_hz: f32, sr: f32, n: usize) -> Vec<f32> {
use std::f32::consts::TAU;
(0..n)
.map(|i| (i as f32 * TAU * freq_hz / sr).sin())
.collect()
}
#[test]
fn test_broadcast_standard_preset_values() {
let cfg = BroadcastLimiterConfig::broadcast_standard(SR);
assert!((cfg.threshold_db - (-1.0)).abs() < 1e-6);
assert!((cfg.lookahead_ms - 5.0).abs() < 1e-6);
assert!((cfg.release_ms - 200.0).abs() < 1e-6);
assert_eq!(cfg.sample_rate, SR);
}
#[test]
fn test_streaming_preset_values() {
let cfg = BroadcastLimiterConfig::streaming(SR);
assert!((cfg.threshold_db - (-2.0)).abs() < 1e-6);
assert!((cfg.lookahead_ms - 3.0).abs() < 1e-6);
}
#[test]
fn test_validate_ok_on_valid_config() {
let cfg = BroadcastLimiterConfig::broadcast_standard(SR);
assert!(cfg.validate().is_ok());
}
#[test]
fn test_validate_rejects_positive_threshold() {
let cfg = BroadcastLimiterConfig {
threshold_db: 1.0,
lookahead_ms: 5.0,
release_ms: 200.0,
sample_rate: SR,
};
assert!(cfg.validate().is_err());
}
#[test]
fn test_validate_rejects_zero_sample_rate() {
let cfg = BroadcastLimiterConfig {
threshold_db: -1.0,
lookahead_ms: 5.0,
release_ms: 200.0,
sample_rate: 0,
};
assert!(cfg.validate().is_err());
}
#[test]
fn test_no_clipping_below_threshold() {
let mut lim = BroadcastLimiter::new(BroadcastLimiterConfig::broadcast_standard(SR));
let input = make_sine(440.0, SR as f32, 2048);
let output = lim.process_buffer(&input);
let threshold_lin = 10.0_f32.powf(-1.0 / 20.0);
for &s in &output {
assert!(
s.abs() <= threshold_lin + 1e-4,
"Output sample {s} exceeds threshold {threshold_lin}"
);
}
}
#[test]
fn test_clips_are_handled() {
let cfg = BroadcastLimiterConfig::broadcast_standard(SR);
let threshold_lin = 10.0_f32.powf(cfg.threshold_db / 20.0);
let mut lim = BroadcastLimiter::new(cfg);
let input = vec![2.0_f32; 2048];
let output = lim.process_buffer(&input);
let settle = lim.lookahead_samples;
let ceiling = threshold_lin * 1.005;
for (i, &s) in output[settle..].iter().enumerate() {
assert!(
s.abs() <= ceiling,
"Sample {i} should be limited: got {s:.7}, ceiling {ceiling:.7}"
);
}
}
#[test]
fn test_gain_reduction_db_negative_when_limiting() {
let mut lim =
BroadcastLimiter::new(BroadcastLimiterConfig::broadcast_standard(SR));
for _ in 0..1024 {
lim.process_sample(2.0);
}
let gr = lim.gain_reduction_db();
assert!(
gr < 0.0,
"gain_reduction_db should be negative when limiting, got {gr}"
);
}
#[test]
fn test_process_buffer_preserves_length() {
let mut lim =
BroadcastLimiter::new(BroadcastLimiterConfig::broadcast_standard(SR));
let input = vec![0.5_f32; 333];
let output = lim.process_buffer(&input);
assert_eq!(output.len(), 333);
}
#[test]
fn test_reset_zeros_state() {
let mut lim =
BroadcastLimiter::new(BroadcastLimiterConfig::broadcast_standard(SR));
for _ in 0..512 {
lim.process_sample(2.0);
}
lim.reset();
assert!(
(lim.current_gain - 1.0).abs() < 1e-6,
"current_gain after reset: {}",
lim.current_gain
);
for &s in &lim.delay_buffer {
assert_eq!(s, 0.0);
}
}
#[test]
fn test_process_sample_returns_gain_db_tuple() {
let mut lim =
BroadcastLimiter::new(BroadcastLimiterConfig::broadcast_standard(SR));
let (output, gain_db) = lim.process_sample(0.1);
assert!(output.is_finite());
assert!(gain_db.is_finite());
assert!(
gain_db >= -0.5,
"No limiting expected for quiet signal; gain_db={gain_db}"
);
}
#[test]
fn test_latency_equals_lookahead_samples() {
let cfg = BroadcastLimiterConfig::broadcast_standard(SR);
let expected = (cfg.lookahead_ms * SR as f32 / 1000.0) as usize;
let lim = BroadcastLimiter::new(cfg);
assert_eq!(lim.latency_samples(), expected.max(1));
}
}