use crate::error::AnalysisError;
#[derive(Debug, Clone, Copy)]
pub enum NormalizationMethod {
Peak,
RMS,
Loudness,
}
#[derive(Debug, Clone)]
pub struct NormalizationConfig {
pub target_loudness_lufs: f32,
pub max_headroom_db: f32,
pub method: NormalizationMethod,
}
impl Default for NormalizationConfig {
fn default() -> Self {
Self {
target_loudness_lufs: -14.0,
max_headroom_db: 1.0,
method: NormalizationMethod::Peak,
}
}
}
#[derive(Debug, Clone)]
pub struct LoudnessMetadata {
pub measured_lufs: Option<f32>,
pub peak_db: f32,
pub rms_db: f32,
pub gain_db: f32,
}
impl Default for LoudnessMetadata {
fn default() -> Self {
Self {
measured_lufs: None,
peak_db: f32::NEG_INFINITY,
rms_db: f32::NEG_INFINITY,
gain_db: 0.0,
}
}
}
const EPSILON: f32 = 1e-10;
const LUFS_GATE_THRESHOLD: f32 = -70.0;
const LUFS_BLOCK_DURATION_MS: f32 = 400.0;
struct KWeightingFilter {
x1: f32,
x2: f32,
b0: f32,
b1: f32,
b2: f32,
a1: f32,
a2: f32,
}
impl KWeightingFilter {
fn new(sample_rate: f32) -> Self {
let w0 = 2.0 * std::f32::consts::PI * 1681.974450955533 / sample_rate;
let cos_w0 = w0.cos();
let sin_w0 = w0.sin();
let alpha = sin_w0 / 2.0 * (1.0f32 / 0.707f32).sqrt();
let b0 = (1.0 + cos_w0) / 2.0;
let b1 = -(1.0 + cos_w0);
let b2 = (1.0 + cos_w0) / 2.0;
let a0 = 1.0 + alpha;
let a1 = -2.0 * cos_w0;
let a2 = 1.0 - alpha;
Self {
x1: 0.0,
x2: 0.0,
b0: b0 / a0,
b1: b1 / a0,
b2: b2 / a0,
a1: a1 / a0,
a2: a2 / a0,
}
}
fn process(&mut self, sample: f32) -> f32 {
let output = self.b0 * sample + self.x1;
self.x1 = self.b1 * sample + self.x2 - self.a1 * output;
self.x2 = self.b2 * sample - self.a2 * output;
output
}
#[allow(dead_code)] fn reset(&mut self) {
self.x1 = 0.0;
self.x2 = 0.0;
}
}
fn calculate_lufs(samples: &[f32], sample_rate: f32) -> Result<f32, AnalysisError> {
if samples.is_empty() {
return Err(AnalysisError::InvalidInput("Empty audio samples".to_string()));
}
if sample_rate <= 0.0 {
return Err(AnalysisError::InvalidInput("Invalid sample rate".to_string()));
}
let block_size = (sample_rate * LUFS_BLOCK_DURATION_MS / 1000.0) as usize;
if block_size == 0 {
return Err(AnalysisError::InvalidInput("Sample rate too low for LUFS calculation".to_string()));
}
let mut filter = KWeightingFilter::new(sample_rate);
let filtered: Vec<f32> = samples.iter().map(|&s| filter.process(s)).collect();
let num_blocks = (filtered.len() + block_size - 1) / block_size;
let mut block_energies = Vec::with_capacity(num_blocks);
for i in 0..num_blocks {
let start = i * block_size;
let end = (start + block_size).min(filtered.len());
let sum_sq: f32 = filtered[start..end].iter().map(|&x| x * x).sum();
let mean_sq = sum_sq / (end - start) as f32;
block_energies.push(mean_sq);
}
if block_energies.is_empty() {
return Err(AnalysisError::ProcessingError("No blocks computed for LUFS".to_string()));
}
let gate_threshold_linear = 10.0_f32.powf((LUFS_GATE_THRESHOLD + 0.691) / 10.0);
let mut gated_energies = Vec::new();
for &energy in &block_energies {
if energy > gate_threshold_linear {
gated_energies.push(energy);
}
}
if gated_energies.is_empty() {
log::warn!("All audio blocks below LUFS gate threshold (-70 LUFS)");
return Ok(f32::NEG_INFINITY);
}
let mean_gated: f32 = gated_energies.iter().sum::<f32>() / gated_energies.len() as f32;
if mean_gated <= EPSILON {
return Err(AnalysisError::NumericalError("Mean square too small for LUFS calculation".to_string()));
}
let lufs = -0.691 + 10.0 * mean_gated.log10();
Ok(lufs)
}
fn normalize_peak(samples: &mut [f32], max_headroom_db: f32) -> Result<LoudnessMetadata, AnalysisError> {
if samples.is_empty() {
return Err(AnalysisError::InvalidInput("Empty audio samples".to_string()));
}
let peak = samples.iter()
.map(|&x| x.abs())
.fold(0.0f32, f32::max);
if peak <= EPSILON {
log::warn!("Audio is silent or extremely quiet, cannot normalize");
return Ok(LoudnessMetadata {
measured_lufs: None,
peak_db: f32::NEG_INFINITY,
rms_db: f32::NEG_INFINITY,
gain_db: 0.0,
});
}
let peak_db = 20.0 * peak.log10();
let target_peak_linear = 10.0_f32.powf((0.0 - max_headroom_db) / 20.0);
let gain_linear = target_peak_linear / peak;
let gain_db = 20.0 * gain_linear.log10();
let gain_linear = gain_linear.min(1.0 / peak);
for sample in samples.iter_mut() {
*sample *= gain_linear;
}
let rms = (samples.iter().map(|&x| x * x).sum::<f32>() / samples.len() as f32).sqrt();
let rms_db = if rms > EPSILON {
20.0 * rms.log10()
} else {
f32::NEG_INFINITY
};
log::debug!("Peak normalization: peak={:.2} dB, gain={:.2} dB", peak_db, gain_db);
Ok(LoudnessMetadata {
measured_lufs: None,
peak_db,
rms_db,
gain_db,
})
}
fn normalize_rms(samples: &mut [f32], target_rms_db: f32, max_headroom_db: f32) -> Result<LoudnessMetadata, AnalysisError> {
if samples.is_empty() {
return Err(AnalysisError::InvalidInput("Empty audio samples".to_string()));
}
let rms_sq = samples.iter().map(|&x| x * x).sum::<f32>() / samples.len() as f32;
let rms = rms_sq.sqrt();
if rms <= EPSILON {
log::warn!("Audio is silent or extremely quiet, cannot normalize");
return Ok(LoudnessMetadata {
measured_lufs: None,
peak_db: f32::NEG_INFINITY,
rms_db: f32::NEG_INFINITY,
gain_db: 0.0,
});
}
let rms_db = 20.0 * rms.log10();
let peak = samples.iter().map(|&x| x.abs()).fold(0.0f32, f32::max);
let peak_db = 20.0 * peak.log10();
let target_rms_linear = 10.0_f32.powf((target_rms_db - max_headroom_db) / 20.0);
let gain_linear = target_rms_linear / rms;
let gain_db = 20.0 * gain_linear.log10();
let new_peak = peak * gain_linear;
if new_peak > 1.0 {
log::warn!("RMS normalization would cause clipping, limiting gain");
let max_gain_linear = 1.0 / peak;
let max_gain_db = 20.0 * max_gain_linear.log10();
for sample in samples.iter_mut() {
*sample *= max_gain_linear;
}
return Ok(LoudnessMetadata {
measured_lufs: None,
peak_db,
rms_db,
gain_db: max_gain_db,
});
}
for sample in samples.iter_mut() {
*sample *= gain_linear;
}
log::debug!("RMS normalization: rms={:.2} dB, gain={:.2} dB", rms_db, gain_db);
Ok(LoudnessMetadata {
measured_lufs: None,
peak_db,
rms_db,
gain_db,
})
}
fn normalize_lufs(
samples: &mut [f32],
target_lufs: f32,
max_headroom_db: f32,
sample_rate: f32,
) -> Result<LoudnessMetadata, AnalysisError> {
if samples.is_empty() {
return Err(AnalysisError::InvalidInput("Empty audio samples".to_string()));
}
let measured_lufs = calculate_lufs(samples, sample_rate)?;
if measured_lufs == f32::NEG_INFINITY {
log::warn!("Audio is too quiet for LUFS measurement, using peak normalization fallback");
return normalize_peak(samples, max_headroom_db);
}
let lufs_diff = target_lufs - measured_lufs;
let gain_db = lufs_diff;
let gain_linear = 10.0_f32.powf(gain_db / 20.0);
let peak = samples.iter().map(|&x| x.abs()).fold(0.0f32, f32::max);
let peak_db = 20.0 * peak.log10();
let new_peak = peak * gain_linear;
let target_peak_linear = 10.0_f32.powf((0.0 - max_headroom_db) / 20.0);
if new_peak > target_peak_linear {
log::warn!("LUFS normalization would cause clipping, limiting gain to preserve headroom");
let max_gain_linear = target_peak_linear / peak;
let max_gain_db = 20.0 * max_gain_linear.log10();
for sample in samples.iter_mut() {
*sample *= max_gain_linear;
}
let rms = (samples.iter().map(|&x| x * x).sum::<f32>() / samples.len() as f32).sqrt();
let rms_db = if rms > EPSILON {
20.0 * rms.log10()
} else {
f32::NEG_INFINITY
};
return Ok(LoudnessMetadata {
measured_lufs: Some(measured_lufs),
peak_db,
rms_db,
gain_db: max_gain_db,
});
}
for sample in samples.iter_mut() {
*sample *= gain_linear;
}
let rms = (samples.iter().map(|&x| x * x).sum::<f32>() / samples.len() as f32).sqrt();
let rms_db = if rms > EPSILON {
20.0 * rms.log10()
} else {
f32::NEG_INFINITY
};
log::debug!("LUFS normalization: measured={:.2} LUFS, target={:.2} LUFS, gain={:.2} dB",
measured_lufs, target_lufs, gain_db);
Ok(LoudnessMetadata {
measured_lufs: Some(measured_lufs),
peak_db,
rms_db,
gain_db,
})
}
pub fn normalize(
samples: &mut [f32],
config: NormalizationConfig,
sample_rate: f32,
) -> Result<LoudnessMetadata, AnalysisError> {
log::debug!("Normalizing {} samples using {:?} at {} Hz",
samples.len(), config.method, sample_rate);
match config.method {
NormalizationMethod::Peak => {
normalize_peak(samples, config.max_headroom_db)
}
NormalizationMethod::RMS => {
let target_rms_db = config.target_loudness_lufs + 3.0;
normalize_rms(samples, target_rms_db, config.max_headroom_db)
}
NormalizationMethod::Loudness => {
normalize_lufs(samples, config.target_loudness_lufs, config.max_headroom_db, sample_rate)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn generate_test_signal(length: usize, amplitude: f32, sample_rate: f32) -> Vec<f32> {
let freq = 440.0;
(0..length)
.map(|i| {
let t = i as f32 / sample_rate;
amplitude * (2.0 * std::f32::consts::PI * freq * t).sin()
})
.collect()
}
#[test]
fn test_peak_normalization() {
let mut samples = generate_test_signal(44100, 0.5, 44100.0);
let config = NormalizationConfig {
method: NormalizationMethod::Peak,
target_loudness_lufs: -14.0,
max_headroom_db: 1.0,
};
let _metadata = normalize(&mut samples, config, 44100.0).unwrap();
let new_peak = samples.iter().map(|&x| x.abs()).fold(0.0f32, f32::max);
let target_peak = 10.0_f32.powf((0.0 - 1.0) / 20.0);
assert!((new_peak - target_peak).abs() < 0.01,
"Peak normalization failed: expected ~{:.3}, got {:.3}", target_peak, new_peak);
assert!(new_peak <= 1.0, "Peak normalization caused clipping: peak = {:.3}", new_peak);
}
#[test]
fn test_rms_normalization() {
let mut samples = generate_test_signal(44100, 0.3, 44100.0);
let config = NormalizationConfig {
method: NormalizationMethod::RMS,
target_loudness_lufs: -14.0,
max_headroom_db: 1.0,
};
let _metadata = normalize(&mut samples, config, 44100.0).unwrap();
let rms = (samples.iter().map(|&x| x * x).sum::<f32>() / samples.len() as f32).sqrt();
let target_rms_db = -14.0 + 3.0; let target_rms = 10.0_f32.powf((target_rms_db - 1.0) / 20.0);
assert!((rms - target_rms).abs() < 0.1,
"RMS normalization failed: expected ~{:.3}, got {:.3}", target_rms, rms);
let peak = samples.iter().map(|&x| x.abs()).fold(0.0f32, f32::max);
assert!(peak <= 1.0, "RMS normalization caused clipping");
}
#[test]
fn test_lufs_calculation() {
let samples = generate_test_signal(48000 * 2, 0.8, 48000.0);
let lufs = calculate_lufs(&samples, 48000.0).unwrap();
assert!(lufs.is_finite(), "LUFS should be finite: {:.2} LUFS", lufs);
assert!(lufs < 0.0, "LUFS should be negative for normalized audio: {:.2} LUFS", lufs);
}
#[test]
fn test_lufs_normalization() {
let mut samples = generate_test_signal(48000 * 2, 0.5, 48000.0);
let config = NormalizationConfig {
method: NormalizationMethod::Loudness,
target_loudness_lufs: -14.0,
max_headroom_db: 1.0,
};
let metadata = normalize(&mut samples, config, 48000.0).unwrap();
assert!(metadata.measured_lufs.is_some(), "LUFS normalization should return measured LUFS");
assert!(metadata.gain_db != 0.0, "Gain should be applied");
let peak = samples.iter().map(|&x| x.abs()).fold(0.0f32, f32::max);
assert!(peak <= 1.0, "LUFS normalization caused clipping: peak = {:.3}", peak);
}
#[test]
fn test_silent_audio() {
let mut samples = vec![0.0f32; 44100];
let config = NormalizationConfig {
method: NormalizationMethod::Peak,
target_loudness_lufs: -14.0,
max_headroom_db: 1.0,
};
let metadata = normalize(&mut samples, config, 44100.0).unwrap();
assert_eq!(metadata.gain_db, 0.0, "Silent audio should not apply gain");
assert_eq!(metadata.peak_db, f32::NEG_INFINITY);
}
#[test]
fn test_ultra_quiet_audio() {
let mut samples = generate_test_signal(44100, 1e-6, 44100.0);
let config = NormalizationConfig {
method: NormalizationMethod::Peak,
target_loudness_lufs: -14.0,
max_headroom_db: 1.0,
};
let _metadata = normalize(&mut samples, config, 44100.0).unwrap();
}
#[test]
fn test_empty_samples() {
let mut samples = vec![];
let config = NormalizationConfig::default();
let result = normalize(&mut samples, config, 44100.0);
assert!(result.is_err(), "Empty samples should return error");
}
#[test]
fn test_k_weighting_filter() {
let sample_rate = 48000.0;
let mut filter = KWeightingFilter::new(sample_rate);
let output = filter.process(1.0);
assert!(!output.is_nan() && !output.is_infinite(),
"Filter output should be finite");
filter.reset();
let test_signal = generate_test_signal(1000, 0.5, sample_rate);
let filtered: Vec<f32> = test_signal.iter().map(|&s| filter.process(s)).collect();
assert_ne!(filtered, test_signal, "K-weighting filter should modify signal");
for &x in &filtered {
assert!(x.is_finite(), "Filter output should be finite");
}
}
}