use crate::{channel_layout::ChannelPosition, sanitize_sample, Audio};
use ebur128::{Channel as EbuChannel, EbuR128, Mode};
#[derive(Clone, Copy, Debug)]
pub struct LoudnessReport {
pub input_lufs: f64,
pub output_lufs: f64,
pub true_peak_dbtp: f64,
pub gain_db: f64,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct LoudnessMetrics {
pub integrated_lufs: f64,
pub momentary_lufs: Option<f64>,
pub short_term_lufs: Option<f64>,
pub loudness_range_lu: Option<f64>,
pub relative_threshold_lufs: Option<f64>,
pub sample_peak_dbfs: f64,
pub true_peak_dbtp: f64,
}
pub fn normalize(
audio: &mut Audio,
target_lufs: f64,
peak_limit_dbtp: f64,
) -> Result<LoudnessReport, String> {
if !target_lufs.is_finite() || !(-70.0..=0.0).contains(&target_lufs) {
return Err("loudness target must be between -70 and 0 LUFS".into());
}
if !peak_limit_dbtp.is_finite() || !(-20.0..=0.0).contains(&peak_limit_dbtp) {
return Err("true-peak limit must be between -20 and 0 dBTP".into());
}
let (input_lufs, input_peak) = measure(audio)?;
let loudness_gain = target_lufs - input_lufs;
let peak_gain = peak_limit_dbtp - input_peak;
let gain_db = loudness_gain.min(peak_gain);
let gain = 10f64.powf(gain_db / 20.0);
for channel in &mut audio.channels {
for sample in channel {
*sample = sanitize_sample(*sample * gain);
}
}
let (output_lufs, true_peak_dbtp) = measure(audio)?;
Ok(LoudnessReport {
input_lufs,
output_lufs,
true_peak_dbtp,
gain_db,
})
}
pub fn measure(audio: &Audio) -> Result<(f64, f64), String> {
let analyzer = create_analyzer(audio, Mode::I | Mode::TRUE_PEAK)?;
let loudness = integrated_loudness(&analyzer)?;
Ok((loudness, true_peak_dbtp(&analyzer, audio.channels())?))
}
pub fn measure_detailed(audio: &Audio) -> Result<LoudnessMetrics, String> {
let analyzer = create_analyzer(
audio,
Mode::I | Mode::S | Mode::LRA | Mode::SAMPLE_PEAK | Mode::TRUE_PEAK,
)?;
let integrated_lufs = integrated_loudness(&analyzer)?;
let momentary_lufs = if has_duration(audio, 400) {
finite_metric(analyzer.loudness_momentary().ok())
} else {
None
};
let short_term_lufs = if has_duration(audio, 3_000) {
finite_metric(analyzer.loudness_shortterm().ok())
} else {
None
};
let loudness_range_lu = if has_duration(audio, 3_000) {
finite_metric(analyzer.loudness_range().ok())
} else {
None
};
let relative_threshold_lufs = finite_metric(analyzer.relative_threshold().ok());
let mut sample_peak = 0.0f64;
for channel in 0..audio.channels() {
sample_peak = sample_peak.max(
analyzer
.sample_peak(channel as u32)
.map_err(|error| format!("measure sample peak: {error}"))?,
);
}
Ok(LoudnessMetrics {
integrated_lufs,
momentary_lufs,
short_term_lufs,
loudness_range_lu,
relative_threshold_lufs,
sample_peak_dbfs: amplitude_to_db(sample_peak),
true_peak_dbtp: true_peak_dbtp(&analyzer, audio.channels())?,
})
}
fn create_analyzer(audio: &Audio, mode: Mode) -> Result<EbuR128, String> {
let channels = audio.channels();
if channels == 0 || audio.frames() == 0 {
return Err("cannot measure empty audio".into());
}
let mut analyzer = EbuR128::new(channels as u32, audio.sample_rate, mode)
.map_err(|error| format!("initialize loudness analyzer: {error}"))?;
if let Some(channel_map) = ebur_channel_map(audio) {
analyzer
.set_channel_map(&channel_map)
.map_err(|error| format!("configure loudness channel map: {error}"))?;
}
let mut interleaved = Vec::with_capacity(audio.frames() * channels);
for frame in 0..audio.frames() {
for channel in &audio.channels {
interleaved.push(sanitize_sample(channel.get(frame).copied().unwrap_or(0.0)));
}
}
analyzer
.add_frames_f64(&interleaved)
.map_err(|error| format!("analyze loudness: {error}"))?;
Ok(analyzer)
}
fn ebur_channel_map(audio: &Audio) -> Option<Vec<EbuChannel>> {
let channels = audio.channels();
let positions = audio
.effective_channel_mask()
.filter(|mask| mask.bits() != 0 && mask.channels() == channels)
.map(|mask| mask.positions())
.or_else(|| audio.channel_layout().mask().map(|mask| mask.positions()))?;
if positions.len() != channels {
return None;
}
Some(positions.into_iter().map(ebu_channel_position).collect())
}
fn ebu_channel_position(position: ChannelPosition) -> EbuChannel {
match position {
ChannelPosition::FrontLeft => EbuChannel::Left,
ChannelPosition::FrontRight => EbuChannel::Right,
ChannelPosition::FrontCenter => EbuChannel::Center,
ChannelPosition::Lfe1 => EbuChannel::Unused,
ChannelPosition::RearLeft => EbuChannel::LeftSurround,
ChannelPosition::RearRight => EbuChannel::RightSurround,
ChannelPosition::FrontLeftCenter => EbuChannel::MpSC,
ChannelPosition::FrontRightCenter => EbuChannel::MmSC,
ChannelPosition::RearCenter => EbuChannel::Mp180,
ChannelPosition::SideLeft => EbuChannel::Mp090,
ChannelPosition::SideRight => EbuChannel::Mm090,
ChannelPosition::TopCenter => EbuChannel::Up000,
ChannelPosition::TopFrontLeft => EbuChannel::Up030,
ChannelPosition::TopFrontCenter => EbuChannel::Up000,
ChannelPosition::TopFrontRight => EbuChannel::Um030,
ChannelPosition::TopRearLeft => EbuChannel::Up110,
ChannelPosition::TopRearCenter => EbuChannel::Up180,
ChannelPosition::TopRearRight => EbuChannel::Um110,
}
}
fn integrated_loudness(analyzer: &EbuR128) -> Result<f64, String> {
let loudness = analyzer
.loudness_global()
.map_err(|error| format!("measure integrated loudness: {error}"))?;
if !loudness.is_finite() {
return Err("integrated loudness is undefined (audio may be silent or too short)".into());
}
Ok(loudness)
}
fn true_peak_dbtp(analyzer: &EbuR128, channels: usize) -> Result<f64, String> {
let mut peak = 0.0f64;
for channel in 0..channels {
peak = peak.max(
analyzer
.true_peak(channel as u32)
.map_err(|error| format!("measure true peak: {error}"))?,
);
}
Ok(amplitude_to_db(peak))
}
fn amplitude_to_db(amplitude: f64) -> f64 {
20.0 * amplitude.max(1e-10).log10()
}
fn finite_metric(value: Option<f64>) -> Option<f64> {
value.filter(|value| value.is_finite())
}
fn has_duration(audio: &Audio, milliseconds: usize) -> bool {
let required_frames = (audio.sample_rate as usize)
.saturating_mul(milliseconds)
.saturating_add(999)
/ 1_000;
audio.frames() >= required_frames
}
#[cfg(test)]
mod tests {
use crate::channel_layout::ChannelLayout;
use super::*;
fn reference_stereo_sine() -> Audio {
let sample_rate = 48_000;
let frames = sample_rate as usize * 5;
let mut channel = Vec::with_capacity(frames);
let mut accumulator = 0.0f32;
let step = 2.0 * std::f32::consts::PI * 440.0 / sample_rate as f32;
for _ in 0..frames {
channel.push(accumulator.sin() as f64);
accumulator += step;
}
Audio {
sample_rate,
channels: vec![channel.clone(), channel],
bits_per_sample: 32,
sample_format: hound::SampleFormat::Float,
channel_mask: None,
}
}
fn sine_channel(sample_rate: u32, seconds: usize, amplitude: f64) -> Vec<f64> {
(0..sample_rate as usize * seconds)
.map(|index| {
let time = index as f64 / sample_rate as f64;
amplitude * (2.0 * std::f64::consts::PI * 440.0 * time).sin()
})
.collect()
}
fn multichannel_audio(
channels: Vec<Vec<f64>>,
channel_mask: Option<crate::channel_layout::ChannelMask>,
) -> Audio {
Audio {
sample_rate: 48_000,
channels,
bits_per_sample: 32,
sample_format: hound::SampleFormat::Float,
channel_mask,
}
}
#[test]
fn reaches_loudness_target_without_exceeding_true_peak() {
let sample_rate = 48_000;
let channel = (0..sample_rate * 2)
.map(|index| {
let time = index as f64 / sample_rate as f64;
0.08 * (2.0 * std::f64::consts::PI * 440.0 * time).sin()
})
.collect();
let mut audio = Audio {
sample_rate,
channels: vec![channel],
bits_per_sample: 32,
sample_format: hound::SampleFormat::Float,
channel_mask: None,
};
let report = normalize(&mut audio, -20.0, -1.0).unwrap();
assert!((report.output_lufs + 20.0).abs() < 0.1);
assert!(report.true_peak_dbtp <= -1.0 + 1e-6);
}
#[test]
fn true_peak_measurement_catches_intersample_overshoot() {
let sample_rate = 48_000;
let mut channel = Vec::with_capacity(sample_rate as usize * 2);
for index in 0..sample_rate as usize * 2 {
channel.push(if index % 2 == 0 { 0.9 } else { -0.9 });
}
let sample_peak = channel.iter().copied().map(f64::abs).fold(0.0, f64::max);
let audio = Audio {
sample_rate,
channels: vec![channel],
bits_per_sample: 32,
sample_format: hound::SampleFormat::Float,
channel_mask: None,
};
let (_, true_peak_dbtp) = measure(&audio).unwrap();
let sample_peak_dbtp = 20.0 * sample_peak.log10();
assert!(
true_peak_dbtp > sample_peak_dbtp + 0.01,
"true peak {true_peak_dbtp:.3} dBTP did not exceed sample peak {sample_peak_dbtp:.3} dB"
);
}
#[test]
fn reports_ebu_r128_reference_metrics() {
let metrics = measure_detailed(&reference_stereo_sine()).unwrap();
assert!((metrics.integrated_lufs + 0.6826).abs() < 1e-3);
assert!(metrics
.momentary_lufs
.is_some_and(|value| (value + 0.6813).abs() < 1e-3));
assert!(metrics
.short_term_lufs
.is_some_and(|value| (value + 0.6828).abs() < 1e-3));
assert!(metrics
.loudness_range_lu
.is_some_and(|value| value.abs() < 1e-3));
assert!(metrics
.relative_threshold_lufs
.is_some_and(|value| (value + 10.6826).abs() < 1e-3));
assert!(metrics.sample_peak_dbfs > -1e-6);
assert!(metrics.true_peak_dbtp > 0.0);
}
#[test]
fn omits_windowed_metrics_when_the_input_is_short() {
let sample_rate = 48_000;
let channel = (0..sample_rate as usize * 2)
.map(|index| {
let time = index as f64 / sample_rate as f64;
0.08 * (2.0 * std::f64::consts::PI * 440.0 * time).sin()
})
.collect();
let audio = Audio {
sample_rate,
channels: vec![channel],
bits_per_sample: 32,
sample_format: hound::SampleFormat::Float,
channel_mask: None,
};
let metrics = measure_detailed(&audio).unwrap();
assert!(metrics.momentary_lufs.is_some());
assert!(metrics.short_term_lufs.is_none());
assert!(metrics.loudness_range_lu.is_none());
}
#[test]
fn excludes_lfe_from_multichannel_loudness() {
let sample_rate = 48_000;
let frames = sample_rate as usize * 2;
let lfe_only = multichannel_audio(
vec![vec![0.0; frames], vec![0.0; frames], vec![1.0; frames]],
ChannelLayout::TwoPointOne.mask(),
);
assert!(measure(&lfe_only).is_err());
let stereo = multichannel_audio(
vec![
sine_channel(sample_rate, 2, 0.1),
sine_channel(sample_rate, 2, 0.1),
],
ChannelLayout::Stereo.mask(),
);
let stereo_with_lfe = multichannel_audio(
vec![
stereo.channels[0].clone(),
stereo.channels[1].clone(),
vec![1.0; frames],
],
ChannelLayout::TwoPointOne.mask(),
);
let (stereo_lufs, _) = measure(&stereo).unwrap();
let (with_lfe_lufs, _) = measure(&stereo_with_lfe).unwrap();
assert!((stereo_lufs - with_lfe_lufs).abs() < 1e-6);
}
#[test]
fn applies_bs1770_surround_weight_and_scans_every_true_peak_channel() {
let sample_rate = 48_000;
let frames = sample_rate as usize * 2;
let mask = ChannelLayout::FivePointZero.mask();
let mut front_channels = vec![vec![0.0; frames]; 5];
front_channels[0] = sine_channel(sample_rate, 2, 0.1);
let mut surround_channels = vec![vec![0.0; frames]; 5];
surround_channels[3] = sine_channel(sample_rate, 2, 0.1);
let (front_lufs, _) = measure(&multichannel_audio(front_channels, mask)).unwrap();
let (surround_lufs, _) = measure(&multichannel_audio(surround_channels, mask)).unwrap();
let surround_gain_db = 10.0 * 1.41f64.log10();
assert!((surround_lufs - front_lufs - surround_gain_db).abs() < 0.05);
let mut true_peak_channels = vec![vec![0.0; frames]; 8];
for (index, sample) in true_peak_channels[7].iter_mut().enumerate() {
*sample = if index % 2 == 0 { 0.9 } else { -0.9 };
}
let metrics = measure_detailed(&multichannel_audio(
true_peak_channels,
ChannelLayout::SevenPointOne.mask(),
))
.unwrap();
assert!((metrics.sample_peak_dbfs - 20.0 * 0.9f64.log10()).abs() < 1e-6);
assert!(metrics.true_peak_dbtp > metrics.sample_peak_dbfs + 0.01);
}
#[test]
fn normalize_sanitizes_nonfinite_and_extreme_samples() {
let mut audio = Audio {
sample_rate: 48_000,
channels: vec![vec![0.1; 48_000]],
bits_per_sample: 32,
sample_format: hound::SampleFormat::Float,
channel_mask: None,
};
audio.channels[0][0] = f64::NAN;
audio.channels[0][1] = f64::INFINITY;
audio.channels[0][2] = 1e300;
normalize(&mut audio, -20.0, -1.0).unwrap();
assert!(audio.channels[0].iter().all(|sample| sample.is_finite()));
assert!(audio.channels[0].iter().all(|sample| sample.abs() <= 1.0));
}
}