#![forbid(unsafe_code)]
use std::collections::VecDeque;
const ABSOLUTE_GATE_LUFS: f64 = -70.0;
const RELATIVE_GATE_OFFSET_LU: f64 = 10.0;
const LRA_LOW_PERCENTILE: f64 = 0.10;
const LRA_HIGH_PERCENTILE: f64 = 0.95;
#[derive(Clone, Debug)]
pub struct LoudnessHistoryConfig {
pub max_blocks: usize,
pub block_duration_secs: f64,
}
impl Default for LoudnessHistoryConfig {
fn default() -> Self {
Self {
max_blocks: 10_000, block_duration_secs: 3.0,
}
}
}
#[derive(Clone, Debug)]
pub struct LoudnessBlock {
pub index: u64,
pub lufs: f64,
pub timestamp_secs: f64,
}
#[derive(Clone, Debug)]
pub struct LoudnessHistoryStats {
pub block_count: usize,
pub min_lufs: f64,
pub max_lufs: f64,
pub mean_lufs: f64,
pub loudness_range_lu: f64,
pub gated_block_count: usize,
}
pub struct LoudnessHistoryTracker {
config: LoudnessHistoryConfig,
blocks: VecDeque<LoudnessBlock>,
next_index: u64,
}
impl LoudnessHistoryTracker {
#[must_use]
pub fn new(config: LoudnessHistoryConfig) -> Self {
let cap = if config.max_blocks == 0 {
64
} else {
config.max_blocks
};
Self {
config,
blocks: VecDeque::with_capacity(cap.min(4096)),
next_index: 0,
}
}
pub fn push_block(&mut self, lufs: f64) {
let index = self.next_index;
let timestamp_secs = index as f64 * self.config.block_duration_secs;
self.next_index += 1;
if self.config.max_blocks > 0 && self.blocks.len() >= self.config.max_blocks {
self.blocks.pop_front();
}
self.blocks.push_back(LoudnessBlock {
index,
lufs,
timestamp_secs,
});
}
pub fn push_blocks(&mut self, values: &[f64]) {
for &v in values {
self.push_block(v);
}
}
#[must_use]
pub fn len(&self) -> usize {
self.blocks.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.blocks.is_empty()
}
#[must_use]
pub fn blocks(&self) -> &VecDeque<LoudnessBlock> {
&self.blocks
}
#[must_use]
pub fn loudness_range(&self) -> f64 {
let gated = self.gated_values();
compute_lra(&gated)
}
#[must_use]
pub fn stats(&self) -> LoudnessHistoryStats {
if self.blocks.is_empty() {
return LoudnessHistoryStats {
block_count: 0,
min_lufs: f64::NEG_INFINITY,
max_lufs: f64::NEG_INFINITY,
mean_lufs: f64::NEG_INFINITY,
loudness_range_lu: 0.0,
gated_block_count: 0,
};
}
let finite_values: Vec<f64> = self
.blocks
.iter()
.map(|b| b.lufs)
.filter(|v| v.is_finite())
.collect();
let min_lufs = finite_values.iter().cloned().fold(f64::INFINITY, f64::min);
let max_lufs = finite_values
.iter()
.cloned()
.fold(f64::NEG_INFINITY, f64::max);
let mean_lufs = if finite_values.is_empty() {
f64::NEG_INFINITY
} else {
finite_values.iter().sum::<f64>() / finite_values.len() as f64
};
let gated = self.gated_values();
let gated_block_count = gated.len();
let loudness_range_lu = compute_lra(&gated);
LoudnessHistoryStats {
block_count: self.blocks.len(),
min_lufs,
max_lufs,
mean_lufs,
loudness_range_lu,
gated_block_count,
}
}
#[must_use]
pub fn export_csv(&self) -> String {
let mut out = String::from("index,timestamp_secs,lufs\n");
for b in &self.blocks {
out.push_str(&format!(
"{},{:.3},{:.2}\n",
b.index, b.timestamp_secs, b.lufs
));
}
out
}
#[must_use]
pub fn export_values(&self) -> Vec<f64> {
self.blocks.iter().map(|b| b.lufs).collect()
}
pub fn clear(&mut self) {
self.blocks.clear();
self.next_index = 0;
}
#[must_use]
pub fn loudest_block_index(&self) -> Option<u64> {
self.blocks
.iter()
.filter(|b| b.lufs.is_finite())
.max_by(|a, b| {
a.lufs
.partial_cmp(&b.lufs)
.unwrap_or(std::cmp::Ordering::Equal)
})
.map(|b| b.index)
}
#[must_use]
pub fn quietest_block_index(&self) -> Option<u64> {
self.blocks
.iter()
.filter(|b| b.lufs.is_finite())
.min_by(|a, b| {
a.lufs
.partial_cmp(&b.lufs)
.unwrap_or(std::cmp::Ordering::Equal)
})
.map(|b| b.index)
}
fn gated_values(&self) -> Vec<f64> {
self.blocks
.iter()
.map(|b| b.lufs)
.filter(|&v| v.is_finite() && v > ABSOLUTE_GATE_LUFS)
.collect()
}
}
fn compute_lra(gated: &[f64]) -> f64 {
if gated.len() < 2 {
return 0.0;
}
let mean = gated.iter().sum::<f64>() / gated.len() as f64;
let relative_gate = mean - RELATIVE_GATE_OFFSET_LU;
let mut relative_gated: Vec<f64> = gated
.iter()
.cloned()
.filter(|&v| v > relative_gate)
.collect();
if relative_gated.len() < 2 {
return 0.0;
}
relative_gated.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let n = relative_gated.len();
let low_idx = ((n as f64 * LRA_LOW_PERCENTILE) as usize).min(n - 1);
let high_idx = ((n as f64 * LRA_HIGH_PERCENTILE) as usize).min(n - 1);
(relative_gated[high_idx] - relative_gated[low_idx]).max(0.0)
}
#[cfg(test)]
mod tests {
use super::*;
fn make_tracker() -> LoudnessHistoryTracker {
LoudnessHistoryTracker::new(LoudnessHistoryConfig::default())
}
#[test]
fn test_empty_tracker() {
let tracker = make_tracker();
assert!(tracker.is_empty());
assert_eq!(tracker.len(), 0);
let stats = tracker.stats();
assert_eq!(stats.block_count, 0);
assert_eq!(stats.loudness_range_lu, 0.0);
}
#[test]
fn test_push_and_len() {
let mut tracker = make_tracker();
tracker.push_block(-23.0);
tracker.push_block(-22.5);
assert_eq!(tracker.len(), 2);
assert!(!tracker.is_empty());
}
#[test]
fn test_max_blocks_evicts_oldest() {
let config = LoudnessHistoryConfig {
max_blocks: 3,
block_duration_secs: 1.0,
};
let mut tracker = LoudnessHistoryTracker::new(config);
for i in 0..5 {
tracker.push_block(-20.0 - i as f64);
}
assert_eq!(tracker.len(), 3);
let values = tracker.export_values();
assert_eq!(values, vec![-22.0, -23.0, -24.0]);
}
#[test]
fn test_lra_constant_signal_is_zero() {
let mut tracker = make_tracker();
tracker.push_blocks(&vec![-23.0f64; 100]);
let lra = tracker.loudness_range();
assert!(lra < 0.01, "lra={}", lra);
}
#[test]
fn test_lra_wide_dynamic_range() {
let mut tracker = make_tracker();
let values: Vec<f64> = (0..200)
.map(|i| -30.0 + (i as f64 * 20.0 / 199.0))
.collect();
tracker.push_blocks(&values);
let lra = tracker.loudness_range();
assert!(lra > 5.0, "lra={}", lra);
}
#[test]
fn test_stats_min_max() {
let mut tracker = make_tracker();
tracker.push_blocks(&[-25.0, -20.0, -30.0]);
let stats = tracker.stats();
assert!((stats.min_lufs - (-30.0)).abs() < 0.001);
assert!((stats.max_lufs - (-20.0)).abs() < 0.001);
}
#[test]
fn test_clear_resets_state() {
let mut tracker = make_tracker();
tracker.push_blocks(&[-23.0, -22.0, -24.0]);
tracker.clear();
assert!(tracker.is_empty());
assert_eq!(tracker.len(), 0);
assert_eq!(tracker.next_index, 0);
}
#[test]
fn test_export_csv_header() {
let mut tracker = make_tracker();
tracker.push_block(-23.0);
let csv = tracker.export_csv();
assert!(csv.starts_with("index,timestamp_secs,lufs\n"));
assert!(csv.contains("-23.00"));
}
#[test]
fn test_export_values_order() {
let mut tracker = make_tracker();
let expected = vec![-23.0, -22.0, -24.0];
tracker.push_blocks(&expected);
assert_eq!(tracker.export_values(), expected);
}
#[test]
fn test_loudest_and_quietest_block() {
let mut tracker = make_tracker();
tracker.push_blocks(&[-25.0, -18.0, -30.0]);
let loudest = tracker.loudest_block_index().expect("non-empty");
let quietest = tracker.quietest_block_index().expect("non-empty");
assert_eq!(loudest, 1);
assert_eq!(quietest, 2);
}
#[test]
fn test_absolute_gate_filters_very_quiet_blocks() {
let mut tracker = make_tracker();
tracker.push_blocks(&[-23.0, -80.0, -22.5, -80.0, -24.0]);
let stats = tracker.stats();
assert_eq!(stats.gated_block_count, 3);
}
#[test]
fn test_block_timestamps_increase() {
let config = LoudnessHistoryConfig {
block_duration_secs: 3.0,
..Default::default()
};
let mut tracker = LoudnessHistoryTracker::new(config);
for _ in 0..5 {
tracker.push_block(-23.0);
}
let blocks: Vec<&LoudnessBlock> = tracker.blocks().iter().collect();
for pair in blocks.windows(2) {
assert!(pair[1].timestamp_secs > pair[0].timestamp_secs);
}
}
#[test]
fn test_stats_mean_lufs() {
let mut tracker = make_tracker();
tracker.push_blocks(&[-20.0, -24.0, -22.0]);
let stats = tracker.stats();
assert!(
(stats.mean_lufs - (-22.0)).abs() < 0.001,
"mean={}",
stats.mean_lufs
);
}
}