use alpha2_media::{Speaker, SpeakerLayout};
use libm::{log10, pow, tan};
pub const ABSOLUTE_GATE_LUFS: f64 = -70.0;
const INTEGRATED_RELATIVE_GATE: f64 = -10.0;
const RANGE_RELATIVE_GATE: f64 = -20.0;
const STEPS_PER_SECOND: u64 = 10;
const MOMENTARY_STEPS: usize = 4;
const SHORT_TERM_STEPS: usize = 30;
const TRUE_PEAK_TAPS: usize = 12;
const BINS_PER_LU: f64 = 100.0;
const BINS: usize = 10_000;
const FLUSH_BELOW: f64 = 1.0e-30;
#[derive(Copy, Clone, Debug, Default, PartialEq)]
pub struct LoudnessReport {
pub integrated_lufs: Option<f64>,
pub loudness_range_lu: Option<f64>,
pub true_peak_dbtp: Option<f64>,
pub sample_peak_dbfs: Option<f64>,
pub momentary_max_lufs: Option<f64>,
pub short_term_max_lufs: Option<f64>,
pub frames: u64,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum LoudnessError {
InvalidShape,
MismatchedBlock,
}
impl std::fmt::Display for LoudnessError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(match self {
Self::InvalidShape => "a loudness meter needs channels and a rate of 8 to 96 kHz",
Self::MismatchedBlock => "the block's channels do not match the meter's",
})
}
}
impl std::error::Error for LoudnessError {}
#[derive(Clone, Debug)]
struct Channel {
weight: f64,
shelf: Biquad64,
high_pass: Biquad64,
history: [f64; TRUE_PEAK_TAPS],
}
#[derive(Copy, Clone, Debug)]
struct Biquad64 {
b: [f64; 3],
a: [f64; 2],
x: [f64; 2],
y: [f64; 2],
}
impl Biquad64 {
const fn new(b: [f64; 3], a: [f64; 2]) -> Self {
Self {
b,
a,
x: [0.0; 2],
y: [0.0; 2],
}
}
fn process(&mut self, input: f64) -> f64 {
let output = self.b[0] * input + self.b[1] * self.x[0] + self.b[2] * self.x[1]
- self.a[0] * self.y[0]
- self.a[1] * self.y[1];
let output = if output.abs() < FLUSH_BELOW {
0.0
} else {
output
};
self.x = [input, self.x[0]];
self.y = [output, self.y[0]];
output
}
}
fn shelf(sample_rate: u32) -> Biquad64 {
let f0 = 1_681.974_450_955_533;
let gain_db = 3.999_843_853_973_347;
let q = 0.707_175_236_955_419_6;
let k = tan(core::f64::consts::PI * f0 / f64::from(sample_rate));
let vh = pow(10.0, gain_db / 20.0);
let vb = pow(vh, 0.499_666_774_154_541_6);
let a0 = 1.0 + k / q + k * k;
Biquad64::new(
[
(vh + vb * k / q + k * k) / a0,
2.0 * (k * k - vh) / a0,
(vh - vb * k / q + k * k) / a0,
],
[2.0 * (k * k - 1.0) / a0, (1.0 - k / q + k * k) / a0],
)
}
fn high_pass(sample_rate: u32) -> Biquad64 {
let f0 = 38.135_470_876_024_44;
let q = 0.500_327_037_323_877_3;
let k = tan(core::f64::consts::PI * f0 / f64::from(sample_rate));
let a0 = 1.0 + k / q + k * k;
Biquad64::new(
[1.0, -2.0, 1.0],
[2.0 * (k * k - 1.0) / a0, (1.0 - k / q + k * k) / a0],
)
}
fn lufs(power: f64) -> f64 {
-0.691 + 10.0 * log10(power)
}
fn decibels(amplitude: f64) -> f64 {
20.0 * log10(amplitude)
}
#[derive(Copy, Clone, Debug, Default)]
struct Step {
energy: f64,
samples: u64,
}
#[derive(Copy, Clone, Debug, Default)]
struct Bin {
count: u64,
power: f64,
}
#[derive(Clone, Debug)]
struct Histogram {
bins: Box<[Bin]>,
count: u64,
power: f64,
}
impl Histogram {
fn new() -> Self {
Self {
bins: vec![Bin::default(); BINS].into_boxed_slice(),
count: 0,
power: 0.0,
}
}
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
fn add(&mut self, power: f64) {
if !(power.is_finite() && power > 0.0) {
return;
}
let loudness = lufs(power);
if loudness <= ABSOLUTE_GATE_LUFS {
return;
}
let index = (((loudness - ABSOLUTE_GATE_LUFS) * BINS_PER_LU) as usize).min(BINS - 1);
let bin = &mut self.bins[index];
bin.count += 1;
bin.power += power;
self.count += 1;
self.power += power;
}
#[allow(clippy::cast_precision_loss)]
fn relative_gate(&self, relative: f64) -> Option<f64> {
(self.count > 0)
.then(|| (lufs(self.power / self.count as f64) + relative).max(ABSOLUTE_GATE_LUFS))
}
#[allow(clippy::cast_precision_loss)]
fn over(&self, threshold: f64) -> impl Iterator<Item = &Bin> {
self.bins
.iter()
.filter(move |bin| bin.count > 0 && lufs(bin.power / bin.count as f64) > threshold)
}
#[allow(clippy::cast_precision_loss)]
fn gated_mean(&self, relative: f64) -> Option<f64> {
let threshold = self.relative_gate(relative)?;
let (power, count) = self
.over(threshold)
.fold((0.0, 0_u64), |(power, count), bin| {
(power + bin.power, count + bin.count)
});
(count > 0).then(|| power / count as f64)
}
#[allow(
clippy::cast_possible_truncation,
clippy::cast_precision_loss,
clippy::cast_sign_loss
)]
fn range(&self, relative: f64) -> Option<f64> {
let threshold = self.relative_gate(relative)?;
let count = self.over(threshold).map(|bin| bin.count).sum::<u64>();
let last = count.checked_sub(1)? as f64;
let at = |percentile: f64| {
let rank = (last * percentile + 0.5) as u64;
let mut below = 0;
self.over(threshold)
.find(|bin| {
below += bin.count;
below > rank
})
.map(|bin| lufs(bin.power / bin.count as f64))
};
Some(at(0.95)? - at(0.10)?)
}
}
#[derive(Clone, Debug)]
pub struct LoudnessMeter {
sample_rate: u32,
channels: Vec<Channel>,
phases: Vec<[f64; TRUE_PEAK_TAPS]>,
open: Step,
step_index: u64,
recent: Vec<Step>,
blocks: Histogram,
short_terms: Histogram,
momentary_max: Option<f64>,
short_term_max: Option<f64>,
true_peak: f64,
sample_peak: f64,
frames: u64,
}
impl LoudnessMeter {
pub fn new(sample_rate: u32, channels: usize) -> Result<Self, LoudnessError> {
let layout = SpeakerLayout::default_for(channels).ok_or(LoudnessError::InvalidShape)?;
Self::for_speakers(sample_rate, layout.speakers())
}
pub fn for_speakers(sample_rate: u32, speakers: &[Speaker]) -> Result<Self, LoudnessError> {
const SURROUND: f64 = 1.41;
let has_sides = speakers
.iter()
.any(|speaker| matches!(speaker, Speaker::SideLeft | Speaker::SideRight));
let weights = speakers
.iter()
.map(|speaker| match speaker {
Speaker::LowFrequency | Speaker::LowFrequency2 => 0.0,
Speaker::SideLeft
| Speaker::SideRight
| Speaker::SideSurroundLeft
| Speaker::SideSurroundRight
| Speaker::SurroundDirectLeft
| Speaker::SurroundDirectRight
| Speaker::WideLeft
| Speaker::WideRight => SURROUND,
Speaker::BackLeft | Speaker::BackRight if !has_sides => SURROUND,
_ => 1.0,
})
.collect::<Vec<_>>();
Self::with_weights(sample_rate, &weights)
}
pub fn with_weights(sample_rate: u32, weights: &[f64]) -> Result<Self, LoudnessError> {
if weights.is_empty()
|| weights
.iter()
.any(|weight| !weight.is_finite() || *weight < 0.0)
|| !(8_000..=96_000).contains(&sample_rate)
{
return Err(LoudnessError::InvalidShape);
}
let factor = if sample_rate < 96_000 { 4 } else { 2 };
Ok(Self {
sample_rate,
channels: weights
.iter()
.map(|&weight| Channel {
weight,
shelf: shelf(sample_rate),
high_pass: high_pass(sample_rate),
history: [0.0; TRUE_PEAK_TAPS],
})
.collect(),
phases: interpolator(factor),
open: Step::default(),
step_index: 0,
recent: Vec::with_capacity(SHORT_TERM_STEPS),
blocks: Histogram::new(),
short_terms: Histogram::new(),
momentary_max: None,
short_term_max: None,
true_peak: 0.0,
sample_peak: 0.0,
frames: 0,
})
}
pub fn push(&mut self, planes: &[&[f32]]) -> Result<(), LoudnessError> {
let Some(frames) = planes.first().map(|plane| plane.len()) else {
return Err(LoudnessError::MismatchedBlock);
};
if planes.len() != self.channels.len() || planes.iter().any(|plane| plane.len() != frames) {
return Err(LoudnessError::MismatchedBlock);
}
for index in 0..frames {
let mut energy = 0.0;
for (channel, plane) in self.channels.iter_mut().zip(planes) {
let sample = plane[index];
let sample = if sample.is_finite() {
f64::from(sample)
} else {
0.0
};
let weighted = channel.high_pass.process(channel.shelf.process(sample));
energy += channel.weight * weighted * weighted;
self.sample_peak = self.sample_peak.max(sample.abs());
channel.history.copy_within(1.., 0);
channel.history[TRUE_PEAK_TAPS - 1] = sample;
for phase in &self.phases {
let interpolated = phase
.iter()
.zip(channel.history.iter().rev())
.fold(0.0, |sum, (tap, value)| sum + tap * value);
self.true_peak = self.true_peak.max(interpolated.abs());
}
}
self.open.energy += energy;
self.open.samples += 1;
self.frames += 1;
if self.frames == self.step_end() {
self.close_step();
}
}
Ok(())
}
fn step_end(&self) -> u64 {
(self.step_index + 1) * u64::from(self.sample_rate) / STEPS_PER_SECOND
}
fn close_step(&mut self) {
if self.recent.len() == SHORT_TERM_STEPS {
self.recent.remove(0);
}
self.recent.push(self.open);
self.open = Step::default();
self.step_index += 1;
if let Some(power) = self.window_power(MOMENTARY_STEPS) {
self.blocks.add(power);
let loudness = lufs(power);
self.momentary_max = Some(self.momentary_max.map_or(loudness, |max| max.max(loudness)));
}
if let Some(power) = self.window_power(SHORT_TERM_STEPS) {
self.short_terms.add(power);
let loudness = lufs(power);
self.short_term_max = Some(
self.short_term_max
.map_or(loudness, |max| max.max(loudness)),
);
}
}
#[allow(clippy::cast_precision_loss)]
fn window_power(&self, steps: usize) -> Option<f64> {
let window = self.recent.get(self.recent.len().checked_sub(steps)?..)?;
let (energy, samples) = window.iter().fold((0.0, 0_u64), |(energy, samples), step| {
(energy + step.energy, samples + step.samples)
});
(samples > 0).then(|| energy / samples as f64)
}
#[must_use]
pub fn momentary_lufs(&self) -> Option<f64> {
self.window_power(MOMENTARY_STEPS).map(lufs)
}
#[must_use]
pub fn short_term_lufs(&self) -> Option<f64> {
self.window_power(SHORT_TERM_STEPS).map(lufs)
}
#[must_use]
pub fn report(&self) -> LoudnessReport {
let peak = |amplitude: f64| (amplitude > 0.0).then(|| decibels(amplitude));
LoudnessReport {
integrated_lufs: self.blocks.gated_mean(INTEGRATED_RELATIVE_GATE).map(lufs),
loudness_range_lu: self.short_terms.range(RANGE_RELATIVE_GATE),
true_peak_dbtp: peak(self.true_peak.max(self.sample_peak)),
sample_peak_dbfs: peak(self.sample_peak),
momentary_max_lufs: self.momentary_max,
short_term_max_lufs: self.short_term_max,
frames: self.frames,
}
}
}
#[allow(clippy::cast_precision_loss)]
fn interpolator(factor: usize) -> Vec<[f64; TRUE_PEAK_TAPS]> {
let length = factor * TRUE_PEAK_TAPS;
let centre = (length as f64 - 1.0) / 2.0;
let prototype = (0..length)
.map(|index| {
let offset = (index as f64 - centre) / factor as f64;
let sinc = if offset.abs() < 1e-12 {
1.0
} else {
let angle = core::f64::consts::PI * offset;
libm::sin(angle) / angle
};
let window = 0.5
- 0.5
* libm::cos(2.0 * core::f64::consts::PI * (index as f64 + 0.5) / length as f64);
sinc * window
})
.collect::<Vec<_>>();
(0..factor)
.map(|phase| {
let mut taps = [0.0; TRUE_PEAK_TAPS];
for (tap, value) in taps.iter_mut().enumerate() {
*value = prototype[phase + tap * factor];
}
let sum = taps.iter().sum::<f64>();
taps.map(|value| value / sum)
})
.collect()
}
pub const LIMITER_MARGIN_DB: f64 = 0.01;
#[derive(Copy, Clone, Debug, PartialEq)]
pub struct LoudnessNormalization {
pub gain_db: f64,
pub integrated_lufs: f64,
pub true_peak_dbtp: f64,
pub peak_limited: bool,
pub limiter_limited: bool,
}
impl LoudnessNormalization {
#[must_use]
pub fn plan(
report: &LoudnessReport,
target_lufs: f64,
ceiling_dbtp: f64,
limiter_threshold: Option<f64>,
) -> Option<Self> {
let integrated = report.integrated_lufs.filter(|value| value.is_finite())?;
if report.true_peak_dbtp.is_some_and(|peak| !peak.is_finite())
|| report
.sample_peak_dbfs
.is_some_and(|peak| !peak.is_finite())
|| limiter_threshold
.is_some_and(|threshold| !(threshold.is_finite() && threshold > 0.0))
{
return None;
}
let to_target = target_lufs - integrated;
let to_ceiling = report
.true_peak_dbtp
.map_or(f64::INFINITY, |peak| ceiling_dbtp - peak);
let to_limiter = match (limiter_threshold, report.sample_peak_dbfs) {
(Some(threshold), Some(peak)) => {
20.0 * libm::log10(threshold) - LIMITER_MARGIN_DB - peak
}
_ => f64::INFINITY,
};
let gain_db = to_target.min(to_ceiling).min(to_limiter);
Some(Self {
gain_db,
integrated_lufs: integrated + gain_db,
true_peak_dbtp: report
.true_peak_dbtp
.map_or(f64::NEG_INFINITY, |peak| peak + gain_db),
peak_limited: gain_db < to_target,
limiter_limited: to_limiter < to_target.min(to_ceiling),
})
}
#[must_use]
pub fn linear_gain(&self) -> f64 {
pow(10.0, self.gain_db / 20.0)
}
}
#[cfg(test)]
#[path = "loudness_tests.rs"]
mod tests;