use biquad::{Biquad, Coefficients, DirectForm2Transposed};
use crate::error::{Error, Result};
const BLOCK_MS: f64 = 400.0;
const BLOCK_OVERLAP: f64 = 0.75;
const ABSOLUTE_GATE_LUFS: f64 = -70.0;
const RELATIVE_GATE_LU: f64 = -10.0;
const LOUDNESS_OFFSET: f64 = -0.691;
fn k_weighting(rate: f64) -> Result<(Coefficients<f64>, Coefficients<f64>)> {
if (rate - 48_000.0).abs() < 1.0 {
let stage1 = Coefficients {
b0: 1.53512485958697,
b1: -2.69169618940638,
b2: 1.19839281085285,
a1: -1.69065929318241,
a2: 0.73248077421585,
};
let stage2 = Coefficients {
b0: 1.0,
b1: -2.0,
b2: 1.0,
a1: -1.99004745483398,
a2: 0.99007225036621,
};
return Ok((stage1, stage2));
}
k_weighting_at(rate)
}
const SHELF_HZ: f64 = 1681.974450955533;
const SHELF_GAIN_DB: f64 = 3.999843853973347;
const SHELF_Q: f64 = 0.7071752369554196;
const SHELF_MID_EXP: f64 = 0.4996667741545416;
const HIGHPASS_HZ: f64 = 38.13547087602444;
const HIGHPASS_Q: f64 = 0.5003270373238773;
fn k_weighting_at(rate: f64) -> Result<(Coefficients<f64>, Coefficients<f64>)> {
let nyquist = rate / 2.0;
if SHELF_HZ >= nyquist {
return Err(Error::InvalidArgument {
arg: "rate",
reason: format!(
"K-weighting needs its {SHELF_HZ:.2} Hz shelf corner below the {nyquist} Hz \
Nyquist limit of {rate} Hz audio; resample to at least 8 kHz before \
measuring loudness"
),
});
}
let k = (std::f64::consts::PI * SHELF_HZ / rate).tan();
let vh = 10f64.powf(SHELF_GAIN_DB / 20.0);
let vb = vh.powf(SHELF_MID_EXP);
let a0 = 1.0 + k / SHELF_Q + k * k;
let shelf = Coefficients {
b0: (vh + vb * k / SHELF_Q + k * k) / a0,
b1: 2.0 * (k * k - vh) / a0,
b2: (vh - vb * k / SHELF_Q + k * k) / a0,
a1: 2.0 * (k * k - 1.0) / a0,
a2: (1.0 - k / SHELF_Q + k * k) / a0,
};
let k = (std::f64::consts::PI * HIGHPASS_HZ / rate).tan();
let a0 = 1.0 + k / HIGHPASS_Q + k * k;
let highpass = Coefficients {
b0: 1.0,
b1: -2.0,
b2: 1.0,
a1: 2.0 * (k * k - 1.0) / a0,
a2: (1.0 - k / HIGHPASS_Q + k * k) / a0,
};
Ok((shelf, highpass))
}
fn block_powers(samples: &[f32], rate: u32) -> Result<Vec<f64>> {
let rate_f = rate as f64;
let (c1, c2) = k_weighting(rate_f)?;
let mut s1 = DirectForm2Transposed::<f64>::new(c1);
let mut s2 = DirectForm2Transposed::<f64>::new(c2);
let weighted: Vec<f32> = samples
.iter()
.map(|&x| s2.run(s1.run(x as f64)) as f32)
.collect();
let block_len = ((BLOCK_MS / 1000.0) * rate_f).round() as usize;
let step = ((block_len as f64) * (1.0 - BLOCK_OVERLAP))
.round()
.max(1.0) as usize;
if block_len == 0 || weighted.len() < block_len {
return Ok(Vec::new());
}
let mut powers = Vec::new();
let mut start = 0usize;
while start + block_len <= weighted.len() {
let sum_sq: f64 = weighted[start..start + block_len]
.iter()
.map(|&s| (s as f64) * (s as f64))
.sum();
powers.push(sum_sq / block_len as f64);
start += step;
}
Ok(powers)
}
fn power_to_lufs(power: f64) -> f64 {
if power <= 0.0 {
return f64::NEG_INFINITY;
}
LOUDNESS_OFFSET + 10.0 * power.log10()
}
pub fn integrated_lufs(samples: &[f32], rate: u32) -> Result<f64> {
if rate == 0 {
return Err(Error::InvalidArgument {
arg: "rate",
reason: "sample rate is 0".to_string(),
});
}
let powers = block_powers(samples, rate)?;
if powers.is_empty() {
return Ok(f64::NEG_INFINITY);
}
let kept: Vec<f64> = powers
.iter()
.copied()
.filter(|&p| power_to_lufs(p) > ABSOLUTE_GATE_LUFS)
.collect();
if kept.is_empty() {
return Ok(f64::NEG_INFINITY);
}
let mean = kept.iter().sum::<f64>() / kept.len() as f64;
let threshold = power_to_lufs(mean) + RELATIVE_GATE_LU;
let final_kept: Vec<f64> = kept
.into_iter()
.filter(|&p| power_to_lufs(p) > threshold)
.collect();
if final_kept.is_empty() {
return Ok(f64::NEG_INFINITY);
}
let final_mean = final_kept.iter().sum::<f64>() / final_kept.len() as f64;
Ok(power_to_lufs(final_mean))
}
pub fn peak_dbfs(samples: &[f32]) -> f64 {
let peak = samples.iter().fold(0.0f64, |a, &b| a.max((b as f64).abs()));
if peak > 0.0 {
20.0 * peak.log10()
} else {
f64::NEG_INFINITY
}
}
pub fn normalize_to_lufs(
samples: &[f32],
rate: u32,
target_lufs: f64,
peak_ceiling_dbfs: f64,
) -> Result<Vec<f32>> {
let current = integrated_lufs(samples, rate)?;
if !current.is_finite() {
return Ok(samples.to_vec());
}
let mut gain = 10f64.powf((target_lufs - current) / 20.0);
let peak = peak_dbfs(samples);
if peak.is_finite() {
let ceiling_gain = 10f64.powf((peak_ceiling_dbfs - peak) / 20.0);
if ceiling_gain < gain {
gain = ceiling_gain;
}
}
Ok(samples.iter().map(|&s| (s as f64 * gain) as f32).collect())
}
#[cfg(test)]
mod tests {
use super::*;
const RATE: u32 = 48_000;
fn tone(amp: f64, secs: f64) -> Vec<f32> {
let n = (RATE as f64 * secs) as usize;
(0..n)
.map(|i| (amp * (std::f64::consts::TAU * 1000.0 * i as f64 / RATE as f64).sin()) as f32)
.collect()
}
#[test]
fn silence_has_no_loudness_rather_than_a_number() {
let lufs = integrated_lufs(&vec![0.0f32; RATE as usize], RATE).unwrap();
assert!(lufs.is_infinite() && lufs < 0.0, "got {lufs}");
}
#[test]
fn input_shorter_than_one_block_is_unmeasurable() {
let lufs = integrated_lufs(&tone(0.5, 0.1), RATE).unwrap();
assert!(
lufs.is_infinite(),
"100 ms should not produce a reading, got {lufs}"
);
assert!(integrated_lufs(&tone(0.5, 1.0), RATE).unwrap().is_finite());
}
#[test]
fn doubling_amplitude_raises_loudness_by_six_db() {
let quiet = integrated_lufs(&tone(0.25, 3.0), RATE).unwrap();
let loud = integrated_lufs(&tone(0.5, 3.0), RATE).unwrap();
assert!(
(loud - quiet - 6.02).abs() < 0.05,
"{quiet:.2} -> {loud:.2} should be +6.02 LU"
);
}
#[test]
fn normalizing_hits_the_target() {
let out = normalize_to_lufs(&tone(0.1, 3.0), RATE, -23.0, -1.0).unwrap();
let got = integrated_lufs(&out, RATE).unwrap();
assert!((got - -23.0).abs() < 0.1, "got {got:.2} LUFS, wanted -23");
}
#[test]
fn the_peak_ceiling_overrides_the_loudness_target() {
let out = normalize_to_lufs(&tone(0.001, 3.0), RATE, -16.0, -1.0).unwrap();
let peak = peak_dbfs(&out);
assert!(
peak <= -1.0 + 1e-6,
"peak {peak:.2} dBFS breached the ceiling"
);
let lufs = integrated_lufs(&out, RATE).unwrap();
assert!(lufs < -16.0, "must fall short of target, got {lufs:.2}");
}
#[test]
fn the_gate_ignores_silence_between_words() {
let speech = tone(0.5, 2.0);
let mut gapped = speech.clone();
gapped.extend(vec![0.0f32; RATE as usize * 4]);
gapped.extend(tone(0.5, 2.0));
let solid = integrated_lufs(&speech, RATE).unwrap();
let with_gaps = integrated_lufs(&gapped, RATE).unwrap();
assert!(
(solid - with_gaps).abs() < 0.5,
"gated loudness {with_gaps:.2} should match ungapped {solid:.2}"
);
let rms_db = |s: &[f32]| {
20.0 * (s.iter().map(|&x| (x as f64).powi(2)).sum::<f64>() / s.len() as f64)
.sqrt()
.log10()
};
assert!(
rms_db(&speech) - rms_db(&gapped) > 2.0,
"RMS should be badly skewed by the gaps"
);
}
#[test]
fn a_minus_20_dbfs_997_hz_sine_reads_minus_23_01_lufs() {
let amp = 10f64.powf(-20.0 / 20.0);
let n = RATE as usize * 10;
let sine: Vec<f32> = (0..n)
.map(|i| (amp * (std::f64::consts::TAU * 997.0 * i as f64 / RATE as f64).sin()) as f32)
.collect();
let lufs = integrated_lufs(&sine, RATE).unwrap();
assert!((lufs - -23.01).abs() < 0.1, "got {lufs:.3} LUFS");
}
#[test]
fn the_k_weighting_design_reproduces_the_48k_literals() {
let (lit1, lit2) = k_weighting(48_000.0).unwrap();
let (d1, d2) = k_weighting_at(48_000.0).unwrap();
for (a, b) in [(lit1, d1), (lit2, d2)] {
for (x, y) in [
(a.b0, b.b0),
(a.b1, b.b1),
(a.b2, b.b2),
(a.a1, b.a1),
(a.a2, b.a2),
] {
assert!((x - y).abs() < 1e-8, "{x} vs {y}");
}
}
}
#[test]
fn the_rebuilt_k_weighting_matches_the_literal_one() {
let tone_at = |rate: u32| -> Vec<f32> {
(0..rate as usize * 5)
.map(|i| {
(0.1 * (std::f64::consts::TAU * 997.0 * i as f64 / rate as f64).sin()) as f32
})
.collect()
};
let at_48k = integrated_lufs(&tone_at(48_000), 48_000).unwrap();
let at_44k = integrated_lufs(&tone_at(44_100), 44_100).unwrap();
assert!((at_48k - at_44k).abs() < 0.05, "{at_48k:.3} vs {at_44k:.3}");
}
#[test]
fn a_zero_sample_rate_is_rejected() {
assert!(integrated_lufs(&tone(0.5, 1.0), 0).is_err());
assert!(integrated_lufs(&tone(0.5, 1.0), 3_000).is_err());
}
}