use crate::util::Biquad;
use ferraus_core::AudioBuffer;
use serde::Serialize;
use std::f64::consts::PI;
#[derive(Clone, Debug, Serialize)]
pub struct LoudnessReport {
pub integrated_lufs: f64,
pub short_term_max_lufs: f64,
pub momentary_max_lufs: f64,
pub loudness_range_lu: f64,
pub relative_gate_lufs: f64,
pub true_peak_dbtp: f64,
pub sample_peak_dbfs: f64,
pub plr_db: f64,
pub duration_s: f64,
#[serde(skip_serializing_if = "Option::is_none")]
pub timeline: Option<LoudnessTimeline>,
}
#[derive(Clone, Debug, Serialize)]
pub struct LoudnessTimeline {
pub step_s: f64,
pub t_s: Vec<f64>,
pub momentary_lufs: Vec<f64>,
pub short_term_lufs: Vec<f64>,
}
#[derive(Clone, Debug, Serialize)]
pub struct LoudnessSection {
pub start_s: f64,
pub end_s: f64,
pub lufs: f64,
pub delta_lu: f64,
}
pub fn k_weighting(sample_rate: f64) -> (Biquad, Biquad) {
let f0 = 1_681.974_450_955_533;
let g = 3.999_843_853_973_347;
let q = 0.707_175_236_955_419_6;
let k = (PI * f0 / sample_rate).tan();
let vh = 10f64.powf(g / 20.0);
let vb = vh.powf(0.499_666_774_154_541_6);
let a0 = 1.0 + k / q + k * k;
let shelf = Biquad {
b0: (vh + vb * k / q + k * k) / a0,
b1: 2.0 * (k * k - vh) / a0,
b2: (vh - vb * k / q + k * k) / a0,
a1: 2.0 * (k * k - 1.0) / a0,
a2: (1.0 - k / q + k * k) / a0,
};
let f0 = 38.135_470_876_024_44;
let q = 0.500_327_037_323_877_3;
let k = (PI * f0 / sample_rate).tan();
let d = 1.0 + k / q + k * k;
let hp = Biquad { b0: 1.0, b1: -2.0, b2: 1.0, a1: 2.0 * (k * k - 1.0) / d, a2: (1.0 - k / q + k * k) / d };
(shelf, hp)
}
pub fn channel_weights(channels: usize) -> Vec<f64> {
match channels {
5 => vec![1.0, 1.0, 1.0, 1.41, 1.41],
6 => vec![1.0, 1.0, 1.0, 0.0, 1.41, 1.41],
n => vec![1.0; n],
}
}
#[inline]
pub fn energy_to_lufs(e: f64) -> f64 {
if e <= 0.0 {
f64::NEG_INFINITY
} else {
-0.691 + 10.0 * e.log10()
}
}
#[inline]
fn lufs_to_energy(l: f64) -> f64 {
10f64.powf((l + 0.691) / 10.0)
}
struct SubBlocks {
sums: Vec<f64>,
lens: Vec<usize>,
sr: f64,
}
impl SubBlocks {
fn new(buf: &AudioBuffer) -> SubBlocks {
let sr = buf.sample_rate as f64;
let n = buf.frames();
let h = sr / 10.0;
let count = (n as f64 / h).floor() as usize;
let bounds: Vec<usize> = (0..=count).map(|k| (k as f64 * h).round() as usize).collect();
let weights = channel_weights(buf.channels());
let (shelf, hp) = k_weighting(sr);
let mut sums = vec![0.0f64; count];
for (c, ch) in buf.data.iter().enumerate() {
let w = weights[c];
if w == 0.0 {
continue;
}
let (mut x1, mut x2, mut y1, mut y2) = (0.0f64, 0.0, 0.0, 0.0);
let (mut u1, mut u2, mut z1, mut z2) = (0.0f64, 0.0, 0.0, 0.0);
let mut blk = 0usize;
let mut acc = 0.0f64;
for (i, &v) in ch.iter().enumerate() {
let v = v as f64;
let y = shelf.b0 * v + shelf.b1 * x1 + shelf.b2 * x2 - shelf.a1 * y1 - shelf.a2 * y2;
x2 = x1;
x1 = v;
y2 = y1;
y1 = y;
let z = hp.b0 * y + hp.b1 * u1 + hp.b2 * u2 - hp.a1 * z1 - hp.a2 * z2;
u2 = u1;
u1 = y;
z2 = z1;
z1 = z;
if blk >= count {
break;
}
acc += z * z;
if i + 1 == bounds[blk + 1] {
sums[blk] += w * acc;
acc = 0.0;
blk += 1;
}
}
}
let lens = bounds.windows(2).map(|w| w[1] - w[0]).collect();
SubBlocks { sums, lens, sr }
}
fn count(&self) -> usize {
self.sums.len()
}
fn window(&self, j: usize, m: usize, pad: bool) -> f64 {
let start = (j + 1).saturating_sub(m);
let s: f64 = self.sums[start..=j].iter().sum();
let mut len: usize = self.lens[start..=j].iter().sum();
if pad && j + 1 < m {
len += ((m - (j + 1)) as f64 * self.sr / 10.0).round() as usize;
}
if len == 0 {
0.0
} else {
s / len as f64
}
}
fn windows(&self, m: usize) -> Vec<f64> {
if self.count() < m {
return Vec::new();
}
(m - 1..self.count()).map(|j| self.window(j, m, false)).collect()
}
}
fn gated_integrated(blocks: &[f64]) -> (f64, f64) {
let abs_e = lufs_to_energy(-70.0);
let above: Vec<f64> = blocks.iter().copied().filter(|e| *e > abs_e).collect();
if above.is_empty() {
return (f64::NEG_INFINITY, f64::NEG_INFINITY);
}
let rel_gate = energy_to_lufs(above.iter().sum::<f64>() / above.len() as f64) - 10.0;
let rel_e = lufs_to_energy(rel_gate);
let gated: Vec<f64> = above.into_iter().filter(|e| *e > rel_e).collect();
if gated.is_empty() {
return (f64::NEG_INFINITY, rel_gate);
}
(energy_to_lufs(gated.iter().sum::<f64>() / gated.len() as f64), rel_gate)
}
fn loudness_range(st: &[f64]) -> f64 {
let abs_e = lufs_to_energy(-70.0);
let above: Vec<f64> = st.iter().copied().filter(|e| *e > abs_e).collect();
if above.is_empty() {
return 0.0;
}
let rel = energy_to_lufs(above.iter().sum::<f64>() / above.len() as f64) - 20.0;
let rel_e = lufs_to_energy(rel);
let mut l: Vec<f64> = above.into_iter().filter(|e| *e > rel_e).map(energy_to_lufs).collect();
if l.is_empty() {
return 0.0;
}
l.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let n = l.len();
let lo = ((n - 1) as f64 * 0.10 + 0.5) as usize;
let hi = ((n - 1) as f64 * 0.95 + 0.5) as usize;
l[hi] - l[lo]
}
pub fn loudness(buf: &AudioBuffer) -> LoudnessReport {
loudness_with(buf, None)
}
pub fn loudness_with(buf: &AudioBuffer, timeline_step_s: Option<f64>) -> LoudnessReport {
let sb = SubBlocks::new(buf);
let mom = sb.windows(4);
let st = sb.windows(30);
let (integrated, rel_gate) = gated_integrated(&mom);
let momentary_max = mom.iter().copied().map(energy_to_lufs).fold(f64::NEG_INFINITY, f64::max);
let short_term_max = if !st.is_empty() {
st.iter().copied().map(energy_to_lufs).fold(f64::NEG_INFINITY, f64::max)
} else if sb.count() > 0 {
energy_to_lufs(sb.window(sb.count() - 1, 30, true))
} else {
f64::NEG_INFINITY
};
let lra = loudness_range(&st);
let tp = true_peak(buf);
let sp = buf.peak() as f64;
let tp_db = crate::util::amp_db(tp);
let timeline = timeline_step_s.map(|step| {
let every = ((step / 0.1).round() as usize).max(1);
let mut t_s = Vec::new();
let mut m = Vec::new();
let mut s = Vec::new();
let mut j = every - 1;
while j < sb.count() {
t_s.push((j + 1) as f64 * 0.1);
m.push(energy_to_lufs(sb.window(j, 4, true)));
s.push(energy_to_lufs(sb.window(j, 30, true)));
j += every;
}
LoudnessTimeline { step_s: every as f64 * 0.1, t_s, momentary_lufs: m, short_term_lufs: s }
});
LoudnessReport {
integrated_lufs: integrated,
short_term_max_lufs: short_term_max,
momentary_max_lufs: momentary_max,
loudness_range_lu: lra,
relative_gate_lufs: rel_gate,
true_peak_dbtp: tp_db,
sample_peak_dbfs: crate::util::amp_db(sp),
plr_db: if integrated.is_finite() { tp_db - integrated } else { f64::NAN },
duration_s: buf.duration_s(),
timeline,
}
}
pub fn integrated_lufs(buf: &AudioBuffer) -> f64 {
let sb = SubBlocks::new(buf);
gated_integrated(&sb.windows(4)).0
}
pub fn short_term_curve(buf: &AudioBuffer) -> Vec<(f64, f64)> {
let sb = SubBlocks::new(buf);
if sb.count() < 30 {
return Vec::new();
}
(29..sb.count()).map(|j| ((j + 1) as f64 * 0.1, energy_to_lufs(sb.window(j, 30, false)))).collect()
}
pub fn loudness_outliers(buf: &AudioBuffer, threshold_lu: f64) -> Vec<LoudnessSection> {
let sb = SubBlocks::new(buf);
let (integrated, _) = gated_integrated(&sb.windows(4));
if !integrated.is_finite() || sb.count() < 30 {
return Vec::new();
}
let mut out: Vec<LoudnessSection> = Vec::new();
let mut j = 29;
while j < sb.count() {
let l = energy_to_lufs(sb.window(j, 30, false));
let d = l - integrated;
if d.abs() > threshold_lu && d > -20.0 {
let start = (j + 1 - 30) as f64 * 0.1;
let end = (j + 1) as f64 * 0.1;
if let Some(last) = out.last_mut() {
if (last.end_s - start).abs() < 1e-9 && last.delta_lu.signum() == d.signum() {
last.end_s = end;
if d.abs() > last.delta_lu.abs() {
last.delta_lu = d;
last.lufs = l;
}
j += 30;
continue;
}
}
out.push(LoudnessSection { start_s: start, end_s: end, lufs: l, delta_lu: d });
}
j += 30;
}
out
}
fn bessel_i0(x: f64) -> f64 {
let mut sum = 1.0;
let mut term = 1.0;
let q = x * x / 4.0;
for k in 1..50 {
term *= q / (k * k) as f64;
sum += term;
if term < 1e-12 * sum {
break;
}
}
sum
}
fn polyphase(factor: usize, taps: usize) -> Vec<Vec<f32>> {
let total = factor * taps;
let fc = 0.5 / factor as f64;
let beta = 8.0;
let mid = (total - 1) as f64 / 2.0;
let i0b = bessel_i0(beta);
let mut h: Vec<f64> = (0..total)
.map(|i| {
let t = i as f64 - mid;
let sinc = if t.abs() < 1e-12 { 2.0 * fc } else { (2.0 * PI * fc * t).sin() / (PI * t) };
let r = (i as f64 - mid) / mid;
let win = bessel_i0(beta * (1.0 - r * r).max(0.0).sqrt()) / i0b;
sinc * win
})
.collect();
let s: f64 = h.iter().sum();
for v in h.iter_mut() {
*v *= factor as f64 / s;
}
(0..factor)
.map(|p| {
let mut ph: Vec<f32> = (0..taps).map(|k| h[p + k * factor] as f32).collect();
ph.reverse();
ph
})
.collect()
}
pub fn true_peak_factor(sample_rate: u32) -> usize {
if sample_rate <= 48_000 {
4
} else if sample_rate <= 96_000 {
2
} else {
1
}
}
pub fn true_peak_channel(x: &[f32], sample_rate: u32) -> f64 {
let sp = x.iter().fold(0.0f32, |m, v| m.max(v.abs())) as f64;
let factor = true_peak_factor(sample_rate);
if factor == 1 || x.is_empty() {
return sp;
}
let taps = 32;
let phases = polyphase(factor, taps);
let mut padded = vec![0.0f32; taps - 1];
padded.extend_from_slice(x);
padded.extend(std::iter::repeat_n(0.0, taps - 1));
let mut peak = 0.0f32;
for m in 0..x.len() + taps - 1 {
let seg = &padded[m..m + taps];
for ph in &phases {
let y: f32 = seg.iter().zip(ph).map(|(a, b)| a * b).sum();
peak = peak.max(y.abs());
}
}
(peak as f64).max(sp)
}
pub fn true_peak(buf: &AudioBuffer) -> f64 {
buf.data.iter().map(|c| true_peak_channel(c, buf.sample_rate)).fold(0.0, f64::max)
}
pub fn true_peak_db(buf: &AudioBuffer) -> f64 {
crate::util::amp_db(true_peak(buf))
}
#[cfg(test)]
mod tests {
use super::*;
use ferraus_core::testing::{sine, stereo};
fn tone(db: f64, secs: f64, sr: u32) -> AudioBuffer {
stereo(sine(sr, 1000.0, secs, ferraus_core::db_to_lin(db) as f32))
}
fn seq(parts: &[(f64, f64)], sr: u32) -> AudioBuffer {
let mut b = tone(parts[0].0, parts[0].1, sr);
for (db, s) in &parts[1..] {
b.append(&tone(*db, *s, sr));
}
b
}
fn reference(buf: &AudioBuffer) -> (f64, f64, f64, f64, f64) {
use ebur128::{EbuR128, Mode};
let mut m = EbuR128::new(
buf.channels() as u32,
buf.sample_rate,
Mode::I | Mode::LRA | Mode::M | Mode::S | Mode::TRUE_PEAK | Mode::SAMPLE_PEAK,
)
.unwrap();
let inter = buf.to_interleaved();
let chunk = (buf.sample_rate / 10) as usize * buf.channels();
let mut mmax = f64::NEG_INFINITY;
let mut smax = f64::NEG_INFINITY;
for (i, c) in inter.chunks(chunk).enumerate() {
m.add_frames_f32(c).unwrap();
if i >= 3 {
mmax = mmax.max(m.loudness_momentary().unwrap());
}
if i >= 29 {
smax = smax.max(m.loudness_shortterm().unwrap());
}
}
let tp = (0..buf.channels() as u32).map(|c| m.true_peak(c).unwrap()).fold(0.0, f64::max);
(m.loudness_global().unwrap(), mmax, smax, m.loudness_range().unwrap(), 20.0 * tp.log10())
}
#[test]
fn k_weighting_matches_spec_coefficients_at_48k() {
let (s, h) = k_weighting(48000.0);
assert!((s.b0 - 1.53512485958697).abs() < 1e-6);
assert!((s.b1 + 2.69169618940638).abs() < 1e-6);
assert!((s.b2 - 1.19839281085285).abs() < 1e-6);
assert!((s.a1 + 1.69065929318241).abs() < 1e-6);
assert!((s.a2 - 0.73248077421585).abs() < 1e-6);
assert!((h.a1 + 1.99004745483398).abs() < 1e-6);
assert!((h.a2 - 0.99007225036621).abs() < 1e-6);
}
#[test]
fn tech3341_tone_minus_23_and_minus_33() {
for (db, sr) in [(-23.0, 48000), (-33.0, 48000), (-23.0, 44100), (-23.0, 96000)] {
let b = tone(db, 20.0, sr);
let r = loudness(&b);
assert!((r.integrated_lufs - db).abs() < 0.1, "{sr} {db}: {}", r.integrated_lufs);
assert!((r.momentary_max_lufs - db).abs() < 0.1);
assert!((r.short_term_max_lufs - db).abs() < 0.1);
assert!(r.loudness_range_lu < 0.1);
}
}
#[test]
fn tech3341_gating_cases() {
let sr = 48000;
let c3 = seq(&[(-36.0, 10.0), (-23.0, 60.0), (-36.0, 10.0)], sr);
let c4 = seq(&[(-72.0, 10.0), (-36.0, 10.0), (-23.0, 60.0), (-36.0, 10.0), (-72.0, 10.0)], sr);
let c5 = seq(&[(-26.0, 20.0), (-20.0, 20.1), (-26.0, 20.0)], sr);
for (name, b) in [("c3", &c3), ("c4", &c4), ("c5", &c5)] {
let r = loudness(b);
assert!((r.integrated_lufs + 23.0).abs() < 0.1, "{name}: {}", r.integrated_lufs);
let (ri, rm, rs, _, _) = reference(b);
assert!((r.integrated_lufs - ri).abs() < 0.1, "{name} vs ebur128: {} {ri}", r.integrated_lufs);
assert!((r.momentary_max_lufs - rm).abs() < 0.1, "{name} M: {} {rm}", r.momentary_max_lufs);
assert!((r.short_term_max_lufs - rs).abs() < 0.1, "{name} S: {} {rs}", r.short_term_max_lufs);
}
}
#[test]
fn tech3342_loudness_range() {
let sr = 48000;
let cases: Vec<(Vec<(f64, f64)>, f64)> = vec![
(vec![(-20.0, 20.0), (-30.0, 20.0)], 10.0),
(vec![(-20.0, 20.0), (-15.0, 20.0)], 5.0),
(vec![(-40.0, 20.0), (-20.0, 20.0)], 20.0),
(vec![(-50.0, 20.0), (-35.0, 20.0), (-20.0, 20.0), (-35.0, 20.0), (-50.0, 20.0)], 15.0),
];
for (parts, want) in cases {
let b = seq(&parts, sr);
let r = loudness(&b);
assert!((r.loudness_range_lu - want).abs() < 1.0, "LRA {} want {want}", r.loudness_range_lu);
let (_, _, _, rl, _) = reference(&b);
assert!((r.loudness_range_lu - rl).abs() < 0.5, "LRA {} vs ebur128 {rl}", r.loudness_range_lu);
}
}
#[test]
fn true_peak_finds_inter_sample_peaks() {
let sr = 48000;
let n = sr as usize;
let amp = 0.5f32;
let x: Vec<f32> = (0..n).map(|i| amp * (PI / 2.0 * i as f64 + PI / 4.0).sin() as f32).collect();
let b = stereo(AudioBuffer::from_mono(sr, x));
let r = loudness(&b);
let want = 20.0 * (amp as f64).log10();
assert!((r.true_peak_dbtp - want).abs() < 0.2, "tp {}", r.true_peak_dbtp);
assert!(r.true_peak_dbtp > r.sample_peak_dbfs + 2.5, "{} {}", r.true_peak_dbtp, r.sample_peak_dbfs);
let (_, _, _, _, rtp) = reference(&b);
assert!((r.true_peak_dbtp - rtp).abs() < 0.3, "tp {} vs ebur128 {rtp}", r.true_peak_dbtp);
}
#[test]
fn silence_and_short_inputs() {
let b = AudioBuffer::new(48000, 2, 48000);
let r = loudness(&b);
assert!(r.integrated_lufs.is_infinite());
assert_eq!(r.true_peak_dbtp, -240.0);
let short = tone(-20.0, 0.2, 48000);
let r = loudness(&short);
assert!(r.integrated_lufs.is_infinite());
let mono = sine(48000, 1000.0, 5.0, ferraus_core::db_to_lin(-20.0) as f32);
let r = loudness(&mono);
assert!((r.integrated_lufs + 23.01).abs() < 0.1, "{}", r.integrated_lufs);
}
#[test]
fn timeline_has_expected_points() {
let b = tone(-23.0, 5.0, 48000);
let r = loudness_with(&b, Some(0.1));
let t = r.timeline.unwrap();
assert_eq!(t.t_s.len(), 50);
assert!((t.momentary_lufs[10] + 23.0).abs() < 0.1);
assert!(t.short_term_lufs[2] < -30.0, "padded short-term starts low");
}
#[test]
fn outlier_sections() {
let b = seq(&[(-20.0, 12.0), (-6.0, 3.0), (-20.0, 15.0)], 48000);
let o = loudness_outliers(&b, 6.0);
assert_eq!(o.len(), 1, "{o:?}");
assert!(o[0].delta_lu > 6.0 && o[0].start_s > 11.9 && o[0].end_s < 15.1);
}
}