pub const SPOTIFY_TARGET_LUFS: f32 = -14.0;
pub const APPLE_TARGET_LUFS: f32 = -16.0;
pub const MAX_LOUDNESS_RANGE: f32 = 15.0;
struct Biquad {
b0: f64,
b1: f64,
b2: f64,
a1: f64,
a2: f64,
z1: f64,
z2: f64,
}
impl Biquad {
#[inline]
fn process(&mut self, x: f64) -> f64 {
let y = self.b0 * x + self.z1;
self.z1 = self.b1 * x - self.a1 * y + self.z2;
self.z2 = self.b2 * x - self.a2 * y;
y
}
}
fn k_weighting_filters(sample_rate: u32) -> (Biquad, Biquad) {
let fs = sample_rate as f64;
let f0 = 1681.974_450_955_533_f64;
let q1 = 0.707_175_236_955_419_6_f64;
let vh = 10f64.powf(3.999_843_853_973_347_f64 / 20.0); let vb = vh.sqrt();
let k1 = (std::f64::consts::PI * f0 / fs).tan();
let norm1 = 1.0 / (1.0 + k1 / q1 + k1 * k1);
let stage1 = Biquad {
b0: (vh + vb * k1 / q1 + k1 * k1) * norm1,
b1: 2.0 * (k1 * k1 - vh) * norm1,
b2: (vh - vb * k1 / q1 + k1 * k1) * norm1,
a1: 2.0 * (k1 * k1 - 1.0) * norm1,
a2: (1.0 - k1 / q1 + k1 * k1) * norm1,
z1: 0.0,
z2: 0.0,
};
let fc = 38.135_470_876_024_44_f64;
let q2 = 0.500_327_037_323_877_3_f64;
let k2 = (std::f64::consts::PI * fc / fs).tan();
let norm2 = 1.0 / (1.0 + k2 / q2 + k2 * k2);
let stage2 = Biquad {
b0: norm2,
b1: -2.0 * norm2,
b2: norm2,
a1: 2.0 * (k2 * k2 - 1.0) * norm2,
a2: (1.0 - k2 / q2 + k2 * k2) * norm2,
z1: 0.0,
z2: 0.0,
};
(stage1, stage2)
}
fn k_weight(samples: &[i16], sample_rate: u32) -> Vec<f64> {
let (mut s1, mut s2) = k_weighting_filters(sample_rate);
samples
.iter()
.map(|&s| {
let x = s as f64 / i16::MAX as f64;
let y1 = s1.process(x);
s2.process(y1)
})
.collect()
}
fn mean_square(samples: &[f64]) -> f64 {
if samples.is_empty() {
return 0.0;
}
samples.iter().map(|&s| s * s).sum::<f64>() / samples.len() as f64
}
const LUFS_OFFSET: f64 = -0.691;
fn ms_to_lufs(ms: f64) -> f32 {
if ms <= 0.0 {
return -144.0;
}
(LUFS_OFFSET + 10.0 * ms.log10()) as f32
}
pub fn integrated_lufs(samples: &[i16], sample_rate: u32) -> f32 {
let weighted = k_weight(samples, sample_rate);
let block_size = (sample_rate as usize * 400) / 1000; let step = (sample_rate as usize * 100) / 1000;
if weighted.len() < block_size {
return -144.0;
}
let blocks: Vec<f64> = (0..)
.map(|i| i * step)
.take_while(|&start| start + block_size <= weighted.len())
.map(|start| mean_square(&weighted[start..start + block_size]))
.collect();
if blocks.is_empty() {
return -144.0;
}
let abs_gate_ms = 10f64.powf((-70.0 - LUFS_OFFSET) / 10.0);
let passing_abs: Vec<f64> = blocks
.iter()
.copied()
.filter(|&ms| ms > abs_gate_ms)
.collect();
if passing_abs.is_empty() {
return -144.0;
}
let ungated_ms = passing_abs.iter().sum::<f64>() / passing_abs.len() as f64;
let ungated_lufs = ms_to_lufs(ungated_ms);
let rel_gate_ms = 10f64.powf(((ungated_lufs - 10.0) as f64 - LUFS_OFFSET) / 10.0);
let passing_rel: Vec<f64> = passing_abs
.iter()
.copied()
.filter(|&ms| ms > rel_gate_ms)
.collect();
if passing_rel.is_empty() {
return -144.0;
}
let final_ms = passing_rel.iter().sum::<f64>() / passing_rel.len() as f64;
ms_to_lufs(final_ms)
}
pub fn loudness_range(samples: &[i16], sample_rate: u32) -> f32 {
let weighted = k_weight(samples, sample_rate);
let block_size = sample_rate as usize * 3; let step = sample_rate as usize;
if weighted.len() < block_size {
return 0.0;
}
let abs_gate_ms = 10f64.powf((-70.0 - LUFS_OFFSET) / 10.0);
let mut block_lufs: Vec<f32> = (0..)
.map(|i| i * step)
.take_while(|&start| start + block_size <= weighted.len())
.filter_map(|start| {
let ms = mean_square(&weighted[start..start + block_size]);
if ms > abs_gate_ms {
Some(ms_to_lufs(ms))
} else {
None
}
})
.collect();
if block_lufs.len() < 2 {
return 0.0;
}
block_lufs.sort_by(|a, b| a.partial_cmp(b).unwrap());
let lo_idx = (block_lufs.len() as f32 * 0.10) as usize;
let hi_idx = (block_lufs.len() as f32 * 0.95) as usize;
let hi_idx = hi_idx.min(block_lufs.len() - 1);
(block_lufs[hi_idx] - block_lufs[lo_idx]).max(0.0)
}
#[derive(Debug, Clone)]
pub struct LufsReport {
pub integrated_lufs: f32,
pub loudness_range: f32,
pub spotify_compliant: bool,
pub apple_compliant: bool,
pub lra_ok: bool,
}
pub fn report(samples: &[i16], sample_rate: u32) -> LufsReport {
let lufs = integrated_lufs(samples, sample_rate);
let lra = loudness_range(samples, sample_rate);
LufsReport {
integrated_lufs: lufs,
loudness_range: lra,
spotify_compliant: lufs >= SPOTIFY_TARGET_LUFS,
apple_compliant: lufs >= APPLE_TARGET_LUFS,
lra_ok: lra <= MAX_LOUDNESS_RANGE,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::f64::consts::PI;
const SR: u32 = 24_000;
fn sine_at_lufs(target_lufs: f32, secs: f32, sr: u32) -> Vec<i16> {
let ms_target = 10f64.powf((target_lufs as f64 - LUFS_OFFSET) / 10.0);
let amplitude = (ms_target * 2.0).sqrt() * i16::MAX as f64;
let n = (sr as f32 * secs) as usize;
(0..n)
.map(|i| {
let v = amplitude * (2.0 * PI * 1000.0 * i as f64 / sr as f64).sin();
v.clamp(i16::MIN as f64, i16::MAX as f64) as i16
})
.collect()
}
#[test]
fn integrated_lufs_approximately_correct() {
let samples = sine_at_lufs(-20.0, 5.0, SR);
let measured = integrated_lufs(&samples, SR);
assert!(
(measured - (-20.0)).abs() < 2.0,
"Expected ~−20 LUFS, got {:.1}",
measured
);
}
#[test]
fn silence_returns_very_low_lufs() {
let samples = vec![0i16; SR as usize * 5];
let measured = integrated_lufs(&samples, SR);
assert!(
measured < -70.0,
"Expected very low LUFS for silence, got {:.1}",
measured
);
}
#[test]
fn louder_signal_has_higher_lufs() {
let quiet = sine_at_lufs(-25.0, 5.0, SR);
let loud = sine_at_lufs(-15.0, 5.0, SR);
let lufs_quiet = integrated_lufs(&quiet, SR);
let lufs_loud = integrated_lufs(&loud, SR);
assert!(
lufs_loud > lufs_quiet,
"Louder signal ({:.1}) should have higher LUFS than quiet ({:.1})",
lufs_loud,
lufs_quiet
);
}
#[test]
fn constant_signal_has_zero_lra() {
let samples = sine_at_lufs(-20.0, 10.0, SR);
let lra = loudness_range(&samples, SR);
assert!(
lra < 3.0,
"Constant signal should have near-zero LRA, got {:.2}",
lra
);
}
#[test]
fn dynamic_signal_has_nonzero_lra() {
let mut samples = sine_at_lufs(-15.0, 5.0, SR);
samples.extend(sine_at_lufs(-30.0, 5.0, SR));
samples.extend(sine_at_lufs(-15.0, 5.0, SR));
let lra = loudness_range(&samples, SR);
assert!(
lra > 1.0,
"Dynamic signal should have noticeable LRA, got {:.2}",
lra
);
}
#[test]
fn lufs_report_fields_are_consistent() {
let samples = sine_at_lufs(-16.0, 5.0, SR);
let r = report(&samples, SR);
assert!(r.apple_compliant, "−16 LUFS should be Apple compliant");
assert!(
!r.spotify_compliant,
"−16 LUFS should not be Spotify compliant"
);
assert!(r.lra_ok, "Constant sine should have acceptable LRA");
}
#[test]
fn k_weighting_filter_does_not_panic_on_any_sample_rate() {
for &sr in &[8000u32, 16000, 22050, 24000, 44100, 48000] {
let samples = sine_at_lufs(-20.0, 2.0, sr);
let _ = integrated_lufs(&samples, sr);
}
}
#[test]
fn short_clip_below_block_size_returns_low_lufs() {
let samples = sine_at_lufs(-18.0, 0.1, SR);
let measured = integrated_lufs(&samples, SR);
assert!(measured < -70.0 || measured > -144.0); }
}