use std::{error::Error, fmt};
mod dsp;
mod validate;
use dsp::{measure_true_peak, weight_channels};
use validate::{validate_normalization, validate_spec};
const LOUDNESS_OFFSET_DB: f64 = -0.691;
const MOMENTARY_SECONDS: f64 = 0.400;
const MOMENTARY_STEP_SECONDS: f64 = 0.100;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LoudnessChannel {
Center,
Left,
Right,
LeftSurround,
RightSurround,
Lfe,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LoudnessLayout {
channels: Vec<LoudnessChannel>,
}
impl LoudnessLayout {
pub fn new(channels: Vec<LoudnessChannel>) -> Result<Self, LoudnessError> {
if channels.is_empty() || channels.len() > 32 {
return Err(LoudnessError::InvalidPolicy {
field: "channel layout",
reason: "must contain between one and 32 channels",
});
}
Ok(Self { channels })
}
pub fn mono() -> Self {
Self {
channels: vec![LoudnessChannel::Center],
}
}
pub fn stereo() -> Self {
Self {
channels: vec![LoudnessChannel::Left, LoudnessChannel::Right],
}
}
pub fn five_point_one() -> Self {
Self {
channels: vec![
LoudnessChannel::Left,
LoudnessChannel::Right,
LoudnessChannel::Center,
LoudnessChannel::Lfe,
LoudnessChannel::LeftSurround,
LoudnessChannel::RightSurround,
],
}
}
pub fn channels(&self) -> &[LoudnessChannel] {
&self.channels
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FrequencyWeighting {
ItuRBs1770K,
Flat,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum GatingPolicy {
EbuR128,
AbsoluteRelative {
absolute_lufs: f64,
relative_lu: f64,
},
None,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct TruePeakPolicy {
pub oversample_factor: usize,
pub taps: usize,
pub max_work: u64,
}
impl Default for TruePeakPolicy {
fn default() -> Self {
Self {
oversample_factor: 4,
taps: 24,
max_work: 100_000_000,
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct LoudnessSpec {
pub sample_rate_hz: u32,
pub layout: LoudnessLayout,
pub frequency_weighting: FrequencyWeighting,
pub gating: GatingPolicy,
pub true_peak: TruePeakPolicy,
pub max_frames: usize,
}
impl Default for LoudnessSpec {
fn default() -> Self {
Self {
sample_rate_hz: 48_000,
layout: LoudnessLayout::stereo(),
frequency_weighting: FrequencyWeighting::ItuRBs1770K,
gating: GatingPolicy::EbuR128,
true_peak: TruePeakPolicy::default(),
max_frames: 16_777_216,
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct MomentaryLoudness {
pub start_frame: usize,
pub mean_square: f64,
pub lufs: Option<f64>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct TruePeakReport {
pub sample_peak: f64,
pub true_peak: f64,
pub true_peak_dbtp: Option<f64>,
pub oversample_factor: usize,
pub work_units: u64,
}
#[derive(Clone, Debug, PartialEq)]
pub struct LoudnessReport {
pub integrated_lufs: Option<f64>,
pub absolute_gated_lufs: Option<f64>,
pub absolute_gate_lufs: Option<f64>,
pub relative_gate_lufs: Option<f64>,
pub momentary: Vec<MomentaryLoudness>,
pub gated_blocks: usize,
pub true_peak: TruePeakReport,
pub spec: LoudnessSpec,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct NormalizationSpec {
pub target_lufs: f64,
pub max_true_peak_dbtp: f64,
pub max_abs_gain_db: f64,
}
#[derive(Clone, Debug, PartialEq)]
pub struct NormalizationReport {
pub samples: Vec<f32>,
pub input: LoudnessReport,
pub output: LoudnessReport,
pub requested_gain_db: f64,
pub applied_gain_db: f64,
pub gain_limited: bool,
pub true_peak_ceiling_exceeded: bool,
pub clipped_samples: usize,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum LoudnessError {
InvalidPolicy {
field: &'static str,
reason: &'static str,
},
MisalignedInput,
NonFiniteSample {
index: usize,
},
FrameLimit {
supplied: usize,
maximum: usize,
},
WorkLimit {
required: u64,
maximum: u64,
},
UndefinedIntegratedLoudness,
SizeOverflow,
}
impl fmt::Display for LoudnessError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidPolicy { field, reason } => {
write!(f, "invalid loudness {field}: {reason}")
}
Self::MisalignedInput => write!(f, "interleaved loudness input ends mid-frame"),
Self::NonFiniteSample { index } => write!(f, "loudness sample {index} is not finite"),
Self::FrameLimit { supplied, maximum } => {
write!(
f,
"loudness input has {supplied} frames, exceeding {maximum}"
)
}
Self::WorkLimit { required, maximum } => {
write!(
f,
"true-peak interpolation needs {required} work, exceeding {maximum}"
)
}
Self::UndefinedIntegratedLoudness => {
write!(f, "integrated loudness is undefined for this signal")
}
Self::SizeOverflow => write!(f, "loudness size arithmetic overflowed"),
}
}
}
impl Error for LoudnessError {}
pub fn measure_loudness(
input: &[f32],
spec: LoudnessSpec,
) -> Result<LoudnessReport, LoudnessError> {
validate_spec(&spec)?;
let channels = spec.layout.channels.len();
if !input.len().is_multiple_of(channels) {
return Err(LoudnessError::MisalignedInput);
}
for (index, sample) in input.iter().copied().enumerate() {
if !sample.is_finite() {
return Err(LoudnessError::NonFiniteSample { index });
}
}
let frames = input.len() / channels;
if frames > spec.max_frames {
return Err(LoudnessError::FrameLimit {
supplied: frames,
maximum: spec.max_frames,
});
}
let weighted = weight_channels(input, &spec);
let momentary = momentary_blocks(&weighted, &spec)?;
let (
absolute_gate_lufs,
relative_gate_lufs,
absolute_gated_lufs,
integrated_lufs,
gated_blocks,
) = integrate_blocks(&momentary, spec.gating);
let true_peak = measure_true_peak(input, &spec)?;
Ok(LoudnessReport {
integrated_lufs,
absolute_gated_lufs,
absolute_gate_lufs,
relative_gate_lufs,
momentary,
gated_blocks,
true_peak,
spec,
})
}
pub fn normalize_loudness(
input: &[f32],
loudness: LoudnessSpec,
normalization: NormalizationSpec,
) -> Result<NormalizationReport, LoudnessError> {
validate_normalization(normalization)?;
let before = measure_loudness(input, loudness.clone())?;
let integrated = before
.integrated_lufs
.ok_or(LoudnessError::UndefinedIntegratedLoudness)?;
let requested_gain_db = normalization.target_lufs - integrated;
let applied_gain_db = requested_gain_db.clamp(
-normalization.max_abs_gain_db,
normalization.max_abs_gain_db,
);
let gain_limited = (applied_gain_db - requested_gain_db).abs() > 1e-12;
let gain = 10.0f64.powf(applied_gain_db / 20.0);
let samples = input
.iter()
.map(|sample| (f64::from(*sample) * gain) as f32)
.collect::<Vec<_>>();
let clipped_samples = samples.iter().filter(|sample| sample.abs() > 1.0).count();
let output = measure_loudness(&samples, loudness)?;
let true_peak_ceiling_exceeded = output
.true_peak
.true_peak_dbtp
.is_some_and(|peak| peak > normalization.max_true_peak_dbtp);
Ok(NormalizationReport {
samples,
input: before,
output,
requested_gain_db,
applied_gain_db,
gain_limited,
true_peak_ceiling_exceeded,
clipped_samples,
})
}
fn momentary_blocks(
weighted: &[f64],
spec: &LoudnessSpec,
) -> Result<Vec<MomentaryLoudness>, LoudnessError> {
let channels = spec.layout.channels.len();
let frames = weighted.len() / channels;
let window = (f64::from(spec.sample_rate_hz) * MOMENTARY_SECONDS).round() as usize;
let step = (f64::from(spec.sample_rate_hz) * MOMENTARY_STEP_SECONDS).round() as usize;
if frames < window {
return Ok(Vec::new());
}
let count = (frames - window) / step + 1;
let mut blocks = Vec::with_capacity(count);
for block in 0..count {
let start = block.checked_mul(step).ok_or(LoudnessError::SizeOverflow)?;
let mut energy = 0.0;
for (channel, position) in spec.layout.channels.iter().enumerate() {
let channel_energy = (start..start + window)
.map(|frame| weighted[frame * channels + channel].powi(2))
.sum::<f64>()
/ window as f64;
energy += channel_weight(*position) * channel_energy;
}
blocks.push(MomentaryLoudness {
start_frame: start,
mean_square: energy,
lufs: loudness_level(energy),
});
}
Ok(blocks)
}
#[allow(clippy::type_complexity)]
fn integrate_blocks(
blocks: &[MomentaryLoudness],
policy: GatingPolicy,
) -> (Option<f64>, Option<f64>, Option<f64>, Option<f64>, usize) {
if policy == GatingPolicy::None {
let integrated = mean_energy(blocks.iter().map(|block| block.mean_square));
return (None, None, integrated, integrated, blocks.len());
}
let (absolute, relative) = match policy {
GatingPolicy::EbuR128 => (-70.0, -10.0),
GatingPolicy::AbsoluteRelative {
absolute_lufs,
relative_lu,
} => (absolute_lufs, relative_lu),
GatingPolicy::None => unreachable!(),
};
let absolute_energies = blocks
.iter()
.filter(|block| block.lufs.is_some_and(|level| level > absolute))
.map(|block| block.mean_square)
.collect::<Vec<_>>();
let absolute_gated = mean_energy(absolute_energies.iter().copied());
let relative_gate = absolute_gated.map(|level| level + relative);
let final_energies = blocks
.iter()
.filter(|block| {
block.lufs.is_some_and(|level| {
level > absolute && relative_gate.is_none_or(|relative| level > relative)
})
})
.map(|block| block.mean_square)
.collect::<Vec<_>>();
let gated_blocks = final_energies.len();
(
Some(absolute),
relative_gate,
absolute_gated,
mean_energy(final_energies.into_iter()),
gated_blocks,
)
}
fn channel_weight(channel: LoudnessChannel) -> f64 {
match channel {
LoudnessChannel::LeftSurround | LoudnessChannel::RightSurround => 1.41,
LoudnessChannel::Lfe => 0.0,
LoudnessChannel::Center | LoudnessChannel::Left | LoudnessChannel::Right => 1.0,
}
}
fn mean_energy(values: impl Iterator<Item = f64>) -> Option<f64> {
let (sum, count) = values.fold((0.0, 0usize), |(sum, count), value| {
(sum + value, count + 1)
});
(count > 0)
.then(|| sum / count as f64)
.and_then(loudness_level)
}
fn loudness_level(mean_square: f64) -> Option<f64> {
(mean_square > 0.0).then(|| LOUDNESS_OFFSET_DB + 10.0 * mean_square.log10())
}