use crate::{Result, TranscodeError};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum LoudnessStandard {
EbuR128,
AtscA85,
AppleMusic,
Spotify,
YouTube,
Amazon,
Tidal,
Deezer,
Custom(i32),
}
#[derive(Debug, Clone)]
pub struct LoudnessTarget {
pub target_lufs: f64,
pub max_true_peak_dbtp: f64,
pub loudness_range: Option<(f64, f64)>,
pub measure_only: bool,
}
impl LoudnessStandard {
#[must_use]
pub fn target_lufs(self) -> f64 {
match self {
Self::EbuR128 => -23.0,
Self::AtscA85 => -24.0,
Self::AppleMusic => -16.0,
Self::Spotify => -14.0,
Self::YouTube => -14.0,
Self::Amazon => -14.0,
Self::Tidal => -14.0,
Self::Deezer => -15.0,
Self::Custom(lufs) => f64::from(lufs),
}
}
#[must_use]
pub fn max_true_peak_dbtp(self) -> f64 {
match self {
Self::EbuR128 => -1.0,
Self::AtscA85 => -2.0,
Self::AppleMusic => -1.0,
Self::Spotify => -2.0,
Self::YouTube => -1.0,
Self::Amazon => -2.0,
Self::Tidal => -1.0,
Self::Deezer => -1.0,
Self::Custom(_) => -1.0,
}
}
#[must_use]
pub fn description(self) -> &'static str {
match self {
Self::EbuR128 => "EBU R128 (European broadcast standard)",
Self::AtscA85 => "ATSC A/85 (US broadcast standard)",
Self::AppleMusic => "Apple Music/iTunes",
Self::Spotify => "Spotify",
Self::YouTube => "YouTube",
Self::Amazon => "Amazon Music",
Self::Tidal => "Tidal",
Self::Deezer => "Deezer",
Self::Custom(_) => "Custom loudness target",
}
}
#[must_use]
pub fn to_target(self) -> LoudnessTarget {
LoudnessTarget {
target_lufs: self.target_lufs(),
max_true_peak_dbtp: self.max_true_peak_dbtp(),
loudness_range: None,
measure_only: false,
}
}
}
impl LoudnessTarget {
#[must_use]
pub fn new(target_lufs: f64) -> Self {
Self {
target_lufs,
max_true_peak_dbtp: -1.0,
loudness_range: None,
measure_only: false,
}
}
#[must_use]
pub fn with_max_true_peak(mut self, dbtp: f64) -> Self {
self.max_true_peak_dbtp = dbtp;
self
}
#[must_use]
pub fn with_loudness_range(mut self, min: f64, max: f64) -> Self {
self.loudness_range = Some((min, max));
self
}
#[must_use]
pub fn measure_only(mut self) -> Self {
self.measure_only = true;
self
}
pub fn validate(&self) -> Result<()> {
if self.target_lufs > 0.0 {
return Err(TranscodeError::NormalizationError(
"Target LUFS must be negative".to_string(),
));
}
if self.target_lufs < -70.0 {
return Err(TranscodeError::NormalizationError(
"Target LUFS too low (< -70 LUFS)".to_string(),
));
}
if self.max_true_peak_dbtp > 0.0 {
return Err(TranscodeError::NormalizationError(
"Maximum true peak must be negative or zero".to_string(),
));
}
if let Some((min, max)) = self.loudness_range {
if min >= max {
return Err(TranscodeError::NormalizationError(
"Invalid loudness range: min must be less than max".to_string(),
));
}
}
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct NormalizationConfig {
pub standard: LoudnessStandard,
pub target: LoudnessTarget,
pub two_pass: bool,
pub linear_only: bool,
pub gate_threshold: f64,
}
impl NormalizationConfig {
#[must_use]
pub fn new(standard: LoudnessStandard) -> Self {
Self {
standard,
target: standard.to_target(),
two_pass: true,
linear_only: true,
gate_threshold: -70.0,
}
}
#[must_use]
pub fn with_two_pass(mut self, enable: bool) -> Self {
self.two_pass = enable;
self
}
#[must_use]
pub fn with_linear_only(mut self, enable: bool) -> Self {
self.linear_only = enable;
self
}
#[must_use]
pub fn with_gate_threshold(mut self, threshold: f64) -> Self {
self.gate_threshold = threshold;
self
}
pub fn validate(&self) -> Result<()> {
self.target.validate()
}
}
impl Default for NormalizationConfig {
fn default() -> Self {
Self::new(LoudnessStandard::EbuR128)
}
}
pub struct AudioNormalizer {
config: NormalizationConfig,
}
impl AudioNormalizer {
#[must_use]
pub fn new(config: NormalizationConfig) -> Self {
Self { config }
}
#[must_use]
pub fn with_standard(standard: LoudnessStandard) -> Self {
Self::new(NormalizationConfig::new(standard))
}
#[must_use]
pub fn target_lufs(&self) -> f64 {
self.config.target.target_lufs
}
#[must_use]
pub fn max_true_peak_dbtp(&self) -> f64 {
self.config.target.max_true_peak_dbtp
}
#[must_use]
pub fn calculate_gain(&self, measured_lufs: f64, measured_peak_dbtp: f64) -> f64 {
let target = self.target_lufs();
let max_peak = self.max_true_peak_dbtp();
let loudness_gain = target - measured_lufs;
let peak_gain = max_peak - measured_peak_dbtp;
loudness_gain.min(peak_gain)
}
#[must_use]
pub fn needs_normalization(&self, measured_lufs: f64, tolerance: f64) -> bool {
let diff = (measured_lufs - self.target_lufs()).abs();
diff > tolerance
}
#[must_use]
pub fn get_filter_string(&self) -> String {
let target = self.target_lufs();
let max_peak = self.max_true_peak_dbtp();
if self.config.two_pass {
format!("loudnorm=I={target}:TP={max_peak}:LRA=11:dual_mono=true")
} else {
format!("loudnorm=I={target}:TP={max_peak}")
}
}
}
#[derive(Debug, Clone)]
pub struct LoudnessMetrics {
pub integrated_lufs: f64,
#[allow(dead_code)]
pub loudness_range: f64,
pub true_peak_dbtp: f64,
#[allow(dead_code)]
pub momentary_max: f64,
#[allow(dead_code)]
pub short_term_max: f64,
}
impl LoudnessMetrics {
#[must_use]
#[allow(dead_code)]
pub fn is_compliant(&self, standard: LoudnessStandard, tolerance: f64) -> bool {
let target = standard.target_lufs();
let max_peak = standard.max_true_peak_dbtp();
let loudness_ok = (self.integrated_lufs - target).abs() <= tolerance;
let peak_ok = self.true_peak_dbtp <= max_peak;
loudness_ok && peak_ok
}
#[must_use]
#[allow(dead_code)]
pub fn compliance_report(&self, standard: LoudnessStandard) -> String {
let target = standard.target_lufs();
let max_peak = standard.max_true_peak_dbtp();
format!(
"Integrated: {:.1} LUFS (target: {:.1} LUFS)\n\
True Peak: {:.1} dBTP (max: {:.1} dBTP)\n\
Loudness Range: {:.1} LU",
self.integrated_lufs, target, self.true_peak_dbtp, max_peak, self.loudness_range
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_loudness_standard_targets() {
assert_eq!(LoudnessStandard::EbuR128.target_lufs(), -23.0);
assert_eq!(LoudnessStandard::AtscA85.target_lufs(), -24.0);
assert_eq!(LoudnessStandard::Spotify.target_lufs(), -14.0);
assert_eq!(LoudnessStandard::YouTube.target_lufs(), -14.0);
}
#[test]
fn test_loudness_standard_peaks() {
assert_eq!(LoudnessStandard::EbuR128.max_true_peak_dbtp(), -1.0);
assert_eq!(LoudnessStandard::AtscA85.max_true_peak_dbtp(), -2.0);
}
#[test]
fn test_custom_standard() {
let custom = LoudnessStandard::Custom(-18);
assert_eq!(custom.target_lufs(), -18.0);
}
#[test]
fn test_loudness_target_validation() {
let valid = LoudnessTarget::new(-23.0);
assert!(valid.validate().is_ok());
let invalid_positive = LoudnessTarget::new(5.0);
assert!(invalid_positive.validate().is_err());
let invalid_too_low = LoudnessTarget::new(-80.0);
assert!(invalid_too_low.validate().is_err());
}
#[test]
fn test_normalizer_gain_calculation() {
let normalizer = AudioNormalizer::with_standard(LoudnessStandard::EbuR128);
let gain = normalizer.calculate_gain(-20.0, -5.0);
assert_eq!(gain, -3.0);
}
#[test]
fn test_normalizer_needs_normalization() {
let normalizer = AudioNormalizer::with_standard(LoudnessStandard::EbuR128);
assert!(!normalizer.needs_normalization(-23.0, 0.5)); assert!(!normalizer.needs_normalization(-23.3, 0.5)); assert!(normalizer.needs_normalization(-20.0, 0.5)); }
#[test]
fn test_normalizer_filter_string() {
let normalizer = AudioNormalizer::with_standard(LoudnessStandard::EbuR128);
let filter = normalizer.get_filter_string();
assert!(filter.contains("loudnorm"));
assert!(filter.contains("I=-23"));
assert!(filter.contains("TP=-1"));
}
#[test]
fn test_loudness_metrics_compliance() {
let metrics = LoudnessMetrics {
integrated_lufs: -23.2,
loudness_range: 8.0,
true_peak_dbtp: -1.5,
momentary_max: -15.0,
short_term_max: -18.0,
};
assert!(metrics.is_compliant(LoudnessStandard::EbuR128, 0.5));
}
#[test]
fn test_loudness_metrics_non_compliant() {
let metrics = LoudnessMetrics {
integrated_lufs: -18.0, loudness_range: 8.0,
true_peak_dbtp: -1.5,
momentary_max: -15.0,
short_term_max: -18.0,
};
assert!(!metrics.is_compliant(LoudnessStandard::EbuR128, 0.5));
}
#[test]
fn test_normalization_config_builder() {
let config = NormalizationConfig::new(LoudnessStandard::Spotify)
.with_two_pass(true)
.with_linear_only(false)
.with_gate_threshold(-50.0);
assert_eq!(config.standard, LoudnessStandard::Spotify);
assert!(config.two_pass);
assert!(!config.linear_only);
assert_eq!(config.gate_threshold, -50.0);
}
}