#![allow(dead_code)]
use std::collections::BTreeMap;
#[derive(Debug, Clone)]
pub struct LoudnessRangeResult {
pub lra_lu: f64,
pub low_percentile_lufs: f64,
pub high_percentile_lufs: f64,
pub integrated_lufs: f64,
pub block_count: usize,
pub block_loudness: Vec<f64>,
}
#[derive(Debug, Clone)]
pub struct LoudnessRangeConfig {
pub block_duration_s: f64,
pub hop_duration_s: f64,
pub absolute_gate_lufs: f64,
pub relative_gate_lu: f64,
pub low_percentile: f64,
pub high_percentile: f64,
}
impl Default for LoudnessRangeConfig {
fn default() -> Self {
Self {
block_duration_s: 3.0,
hop_duration_s: 1.0,
absolute_gate_lufs: -70.0,
relative_gate_lu: -20.0,
low_percentile: 10.0,
high_percentile: 95.0,
}
}
}
#[derive(Debug, Clone)]
pub struct LoudnessRangeAnalyzer {
config: LoudnessRangeConfig,
}
impl LoudnessRangeAnalyzer {
#[must_use]
pub fn new(config: LoudnessRangeConfig) -> Self {
Self { config }
}
#[must_use]
pub fn default_ebu() -> Self {
Self::new(LoudnessRangeConfig::default())
}
#[allow(clippy::cast_precision_loss)]
#[must_use]
pub fn analyze(&self, samples: &[f32], sample_rate: f64) -> Option<LoudnessRangeResult> {
if samples.is_empty() || sample_rate <= 0.0 {
return None;
}
let block_len = (self.config.block_duration_s * sample_rate) as usize;
let hop_len = (self.config.hop_duration_s * sample_rate).max(1.0) as usize;
if block_len == 0 || block_len > samples.len() {
return None;
}
let mut block_loudness: Vec<f64> = Vec::new();
let mut pos = 0;
while pos + block_len <= samples.len() {
let block = &samples[pos..pos + block_len];
let lufs = compute_block_lufs(block);
block_loudness.push(lufs);
pos += hop_len;
}
if block_loudness.is_empty() {
return None;
}
let after_abs: Vec<f64> = block_loudness
.iter()
.copied()
.filter(|&l| l > self.config.absolute_gate_lufs)
.collect();
if after_abs.is_empty() {
return Some(LoudnessRangeResult {
lra_lu: 0.0,
low_percentile_lufs: self.config.absolute_gate_lufs,
high_percentile_lufs: self.config.absolute_gate_lufs,
integrated_lufs: self.config.absolute_gate_lufs,
block_count: block_loudness.len(),
block_loudness,
});
}
let integrated = energy_mean(&after_abs);
let relative_threshold = integrated + self.config.relative_gate_lu;
let mut gated: Vec<f64> = after_abs
.iter()
.copied()
.filter(|&l| l > relative_threshold)
.collect();
if gated.is_empty() {
return Some(LoudnessRangeResult {
lra_lu: 0.0,
low_percentile_lufs: integrated,
high_percentile_lufs: integrated,
integrated_lufs: integrated,
block_count: block_loudness.len(),
block_loudness,
});
}
gated.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let low = percentile_sorted(&gated, self.config.low_percentile);
let high = percentile_sorted(&gated, self.config.high_percentile);
Some(LoudnessRangeResult {
lra_lu: high - low,
low_percentile_lufs: low,
high_percentile_lufs: high,
integrated_lufs: integrated,
block_count: block_loudness.len(),
block_loudness,
})
}
}
#[allow(clippy::cast_precision_loss)]
fn compute_block_lufs(block: &[f32]) -> f64 {
if block.is_empty() {
return -100.0;
}
let mean_sq: f64 = block
.iter()
.map(|&s| f64::from(s) * f64::from(s))
.sum::<f64>()
/ block.len() as f64;
if mean_sq <= 0.0 {
-100.0
} else {
-0.691 + 10.0 * mean_sq.log10()
}
}
fn energy_mean(values: &[f64]) -> f64 {
if values.is_empty() {
return -100.0;
}
let sum: f64 = values.iter().map(|&l| 10.0_f64.powf(l / 10.0)).sum();
10.0 * (sum / values.len() as f64).log10()
}
fn percentile_sorted(sorted: &[f64], pct: f64) -> f64 {
if sorted.is_empty() {
return 0.0;
}
if sorted.len() == 1 {
return sorted[0];
}
let idx = (pct / 100.0) * (sorted.len() - 1) as f64;
let lo = idx.floor() as usize;
let hi = idx.ceil().min((sorted.len() - 1) as f64) as usize;
let frac = idx - lo as f64;
sorted[lo] * (1.0 - frac) + sorted[hi] * frac
}
#[allow(clippy::cast_precision_loss)]
#[must_use]
pub fn loudness_histogram(values: &[f64], bin_width_lu: f64) -> BTreeMap<i32, usize> {
let mut hist = BTreeMap::new();
for &v in values {
let bin = (v / bin_width_lu).floor() as i32;
*hist.entry(bin).or_insert(0) += 1;
}
hist
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DynamicsClass {
VeryCompressed,
Compressed,
Moderate,
Wide,
VeryWide,
}
#[must_use]
pub fn classify_dynamics(lra_lu: f64) -> DynamicsClass {
if lra_lu < 5.0 {
DynamicsClass::VeryCompressed
} else if lra_lu < 10.0 {
DynamicsClass::Compressed
} else if lra_lu < 18.0 {
DynamicsClass::Moderate
} else if lra_lu < 25.0 {
DynamicsClass::Wide
} else {
DynamicsClass::VeryWide
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sine_samples(freq: f64, sample_rate: f64, duration_s: f64, amplitude: f32) -> Vec<f32> {
let n = (sample_rate * duration_s) as usize;
(0..n)
.map(|i| {
let t = i as f64 / sample_rate;
(amplitude as f64 * (2.0 * std::f64::consts::PI * freq * t).sin()) as f32
})
.collect()
}
#[test]
fn test_default_config() {
let cfg = LoudnessRangeConfig::default();
assert!((cfg.block_duration_s - 3.0).abs() < f64::EPSILON);
assert!((cfg.hop_duration_s - 1.0).abs() < f64::EPSILON);
assert!((cfg.absolute_gate_lufs - (-70.0)).abs() < f64::EPSILON);
}
#[test]
fn test_empty_samples() {
let analyzer = LoudnessRangeAnalyzer::default_ebu();
assert!(analyzer.analyze(&[], 44100.0).is_none());
}
#[test]
fn test_zero_sample_rate() {
let analyzer = LoudnessRangeAnalyzer::default_ebu();
let samples = vec![0.0f32; 1000];
assert!(analyzer.analyze(&samples, 0.0).is_none());
}
#[test]
fn test_silence_loudness() {
let analyzer = LoudnessRangeAnalyzer::default_ebu();
let samples = vec![0.0f32; 44100 * 5];
let result = analyzer.analyze(&samples, 44100.0);
if let Some(r) = result {
assert!((r.lra_lu - 0.0).abs() < 1e-6);
}
}
#[test]
fn test_constant_amplitude_lra_zero() {
let samples = sine_samples(440.0, 44100.0, 10.0, 0.5);
let analyzer = LoudnessRangeAnalyzer::default_ebu();
let result = analyzer.analyze(&samples, 44100.0).unwrap();
assert!(
result.lra_lu < 1.0,
"LRA for constant sine should be near 0, got {}",
result.lra_lu
);
}
#[test]
fn test_varying_amplitude_nonzero_lra() {
let quiet = sine_samples(440.0, 44100.0, 5.0, 0.01);
let loud = sine_samples(440.0, 44100.0, 5.0, 0.8);
let mut samples = quiet;
samples.extend(loud);
let analyzer = LoudnessRangeAnalyzer::default_ebu();
let result = analyzer.analyze(&samples, 44100.0).unwrap();
assert!(
result.lra_lu > 1.0,
"LRA should be > 1 for varying amplitude"
);
}
#[test]
fn test_block_count() {
let dur_s = 10.0;
let sr = 44100.0;
let samples = sine_samples(440.0, sr, dur_s, 0.5);
let analyzer = LoudnessRangeAnalyzer::default_ebu();
let result = analyzer.analyze(&samples, sr).unwrap();
assert!(result.block_count >= 7);
}
#[test]
fn test_compute_block_lufs_silence() {
let block = vec![0.0f32; 1024];
let lufs = compute_block_lufs(&block);
assert!((lufs - (-100.0)).abs() < f64::EPSILON);
}
#[test]
fn test_compute_block_lufs_full_scale() {
let block = sine_samples(1000.0, 44100.0, 0.1, 1.0);
let lufs = compute_block_lufs(&block);
assert!(lufs > -5.0 && lufs < -2.0, "Full-scale sine LUFS: {lufs}");
}
#[test]
fn test_percentile_sorted_single() {
assert!((percentile_sorted(&[42.0], 50.0) - 42.0).abs() < f64::EPSILON);
}
#[test]
fn test_percentile_sorted_range() {
let vals: Vec<f64> = (0..=100).map(|i| i as f64).collect();
let p50 = percentile_sorted(&vals, 50.0);
assert!((p50 - 50.0).abs() < 0.01);
let p10 = percentile_sorted(&vals, 10.0);
assert!((p10 - 10.0).abs() < 0.01);
}
#[test]
fn test_loudness_histogram() {
let values = vec![-20.0, -19.5, -18.0, -10.0, -10.5];
let hist = loudness_histogram(&values, 1.0);
assert!(hist.contains_key(&-20));
assert!(hist.contains_key(&-18));
assert!(hist.contains_key(&-10));
assert!(hist.contains_key(&-11));
}
#[test]
fn test_classify_dynamics() {
assert_eq!(classify_dynamics(3.0), DynamicsClass::VeryCompressed);
assert_eq!(classify_dynamics(7.0), DynamicsClass::Compressed);
assert_eq!(classify_dynamics(14.0), DynamicsClass::Moderate);
assert_eq!(classify_dynamics(20.0), DynamicsClass::Wide);
assert_eq!(classify_dynamics(30.0), DynamicsClass::VeryWide);
}
#[test]
fn test_energy_mean_single() {
let val = energy_mean(&[-23.0]);
assert!((val - (-23.0)).abs() < 0.01);
}
#[test]
fn test_energy_mean_empty() {
assert!((energy_mean(&[]) - (-100.0)).abs() < f64::EPSILON);
}
}