Skip to main content

oximedia_transcode/
normalization.rs

1//! Audio loudness normalization for broadcast and streaming compliance.
2
3use crate::{Result, TranscodeError};
4use serde::{Deserialize, Serialize};
5
6/// Loudness measurement standards.
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
8pub enum LoudnessStandard {
9    /// EBU R128 (European Broadcasting Union) - Target: -23 LUFS.
10    EbuR128,
11    /// ATSC A/85 (US broadcast) - Target: -24 LKFS.
12    AtscA85,
13    /// Apple iTunes/Apple Music - Target: -16 LUFS.
14    AppleMusic,
15    /// Spotify - Target: -14 LUFS.
16    Spotify,
17    /// `YouTube` - Target: -13 to -15 LUFS.
18    YouTube,
19    /// Amazon Music - Target: -14 LUFS.
20    Amazon,
21    /// Tidal - Target: -14 LUFS.
22    Tidal,
23    /// Deezer - Target: -15 LUFS.
24    Deezer,
25    /// Custom target loudness.
26    Custom(i32),
27}
28
29/// Loudness target configuration.
30#[derive(Debug, Clone)]
31pub struct LoudnessTarget {
32    /// Target integrated loudness in LUFS/LKFS.
33    pub target_lufs: f64,
34    /// Maximum true peak level in dBTP.
35    pub max_true_peak_dbtp: f64,
36    /// Loudness range tolerance in LU.
37    pub loudness_range: Option<(f64, f64)>,
38    /// Whether to measure loudness only (no normalization).
39    pub measure_only: bool,
40}
41
42impl LoudnessStandard {
43    /// Gets the target loudness in LUFS/LKFS.
44    #[must_use]
45    pub fn target_lufs(self) -> f64 {
46        match self {
47            Self::EbuR128 => -23.0,
48            Self::AtscA85 => -24.0,
49            Self::AppleMusic => -16.0,
50            Self::Spotify => -14.0,
51            Self::YouTube => -14.0,
52            Self::Amazon => -14.0,
53            Self::Tidal => -14.0,
54            Self::Deezer => -15.0,
55            Self::Custom(lufs) => f64::from(lufs),
56        }
57    }
58
59    /// Gets the maximum true peak level in dBTP.
60    #[must_use]
61    pub fn max_true_peak_dbtp(self) -> f64 {
62        match self {
63            Self::EbuR128 => -1.0,
64            Self::AtscA85 => -2.0,
65            Self::AppleMusic => -1.0,
66            Self::Spotify => -2.0,
67            Self::YouTube => -1.0,
68            Self::Amazon => -2.0,
69            Self::Tidal => -1.0,
70            Self::Deezer => -1.0,
71            Self::Custom(_) => -1.0,
72        }
73    }
74
75    /// Gets a human-readable description of the standard.
76    #[must_use]
77    pub fn description(self) -> &'static str {
78        match self {
79            Self::EbuR128 => "EBU R128 (European broadcast standard)",
80            Self::AtscA85 => "ATSC A/85 (US broadcast standard)",
81            Self::AppleMusic => "Apple Music/iTunes",
82            Self::Spotify => "Spotify",
83            Self::YouTube => "YouTube",
84            Self::Amazon => "Amazon Music",
85            Self::Tidal => "Tidal",
86            Self::Deezer => "Deezer",
87            Self::Custom(_) => "Custom loudness target",
88        }
89    }
90
91    /// Converts to a loudness target configuration.
92    #[must_use]
93    pub fn to_target(self) -> LoudnessTarget {
94        LoudnessTarget {
95            target_lufs: self.target_lufs(),
96            max_true_peak_dbtp: self.max_true_peak_dbtp(),
97            loudness_range: None,
98            measure_only: false,
99        }
100    }
101}
102
103impl LoudnessTarget {
104    /// Creates a new loudness target with specified LUFS.
105    #[must_use]
106    pub fn new(target_lufs: f64) -> Self {
107        Self {
108            target_lufs,
109            max_true_peak_dbtp: -1.0,
110            loudness_range: None,
111            measure_only: false,
112        }
113    }
114
115    /// Sets the maximum true peak level.
116    #[must_use]
117    pub fn with_max_true_peak(mut self, dbtp: f64) -> Self {
118        self.max_true_peak_dbtp = dbtp;
119        self
120    }
121
122    /// Sets the loudness range tolerance.
123    #[must_use]
124    pub fn with_loudness_range(mut self, min: f64, max: f64) -> Self {
125        self.loudness_range = Some((min, max));
126        self
127    }
128
129    /// Sets measure-only mode (no normalization applied).
130    #[must_use]
131    pub fn measure_only(mut self) -> Self {
132        self.measure_only = true;
133        self
134    }
135
136    /// Validates the loudness target configuration.
137    ///
138    /// # Errors
139    ///
140    /// Returns an error if the configuration is invalid.
141    pub fn validate(&self) -> Result<()> {
142        if self.target_lufs > 0.0 {
143            return Err(TranscodeError::NormalizationError(
144                "Target LUFS must be negative".to_string(),
145            ));
146        }
147
148        if self.target_lufs < -70.0 {
149            return Err(TranscodeError::NormalizationError(
150                "Target LUFS too low (< -70 LUFS)".to_string(),
151            ));
152        }
153
154        if self.max_true_peak_dbtp > 0.0 {
155            return Err(TranscodeError::NormalizationError(
156                "Maximum true peak must be negative or zero".to_string(),
157            ));
158        }
159
160        if let Some((min, max)) = self.loudness_range {
161            if min >= max {
162                return Err(TranscodeError::NormalizationError(
163                    "Invalid loudness range: min must be less than max".to_string(),
164                ));
165            }
166        }
167
168        Ok(())
169    }
170}
171
172/// Normalization configuration.
173#[derive(Debug, Clone)]
174pub struct NormalizationConfig {
175    /// Loudness standard to use.
176    pub standard: LoudnessStandard,
177    /// Target configuration.
178    pub target: LoudnessTarget,
179    /// Enable two-pass normalization for better accuracy.
180    pub two_pass: bool,
181    /// Enable linear gain only (no dynamic range compression).
182    pub linear_only: bool,
183    /// Gate threshold for loudness measurement in LUFS.
184    pub gate_threshold: f64,
185}
186
187impl NormalizationConfig {
188    /// Creates a new normalization config with the specified standard.
189    #[must_use]
190    pub fn new(standard: LoudnessStandard) -> Self {
191        Self {
192            standard,
193            target: standard.to_target(),
194            two_pass: true,
195            linear_only: true,
196            gate_threshold: -70.0,
197        }
198    }
199
200    /// Sets two-pass mode.
201    #[must_use]
202    pub fn with_two_pass(mut self, enable: bool) -> Self {
203        self.two_pass = enable;
204        self
205    }
206
207    /// Sets linear-only mode.
208    #[must_use]
209    pub fn with_linear_only(mut self, enable: bool) -> Self {
210        self.linear_only = enable;
211        self
212    }
213
214    /// Sets the gate threshold.
215    #[must_use]
216    pub fn with_gate_threshold(mut self, threshold: f64) -> Self {
217        self.gate_threshold = threshold;
218        self
219    }
220
221    /// Validates the configuration.
222    ///
223    /// # Errors
224    ///
225    /// Returns an error if the configuration is invalid.
226    pub fn validate(&self) -> Result<()> {
227        self.target.validate()
228    }
229}
230
231impl Default for NormalizationConfig {
232    fn default() -> Self {
233        Self::new(LoudnessStandard::EbuR128)
234    }
235}
236
237/// Audio normalizer for applying loudness normalization.
238pub struct AudioNormalizer {
239    config: NormalizationConfig,
240}
241
242impl AudioNormalizer {
243    /// Creates a new audio normalizer with the specified configuration.
244    #[must_use]
245    pub fn new(config: NormalizationConfig) -> Self {
246        Self { config }
247    }
248
249    /// Creates a normalizer with the specified standard.
250    #[must_use]
251    pub fn with_standard(standard: LoudnessStandard) -> Self {
252        Self::new(NormalizationConfig::new(standard))
253    }
254
255    /// Gets the target loudness in LUFS.
256    #[must_use]
257    pub fn target_lufs(&self) -> f64 {
258        self.config.target.target_lufs
259    }
260
261    /// Gets the maximum true peak in dBTP.
262    #[must_use]
263    pub fn max_true_peak_dbtp(&self) -> f64 {
264        self.config.target.max_true_peak_dbtp
265    }
266
267    /// Calculates the gain adjustment needed for normalization.
268    ///
269    /// # Arguments
270    ///
271    /// * `measured_lufs` - The measured integrated loudness
272    /// * `measured_peak_dbtp` - The measured true peak
273    ///
274    /// # Returns
275    ///
276    /// The gain adjustment in dB, limited to prevent clipping.
277    #[must_use]
278    pub fn calculate_gain(&self, measured_lufs: f64, measured_peak_dbtp: f64) -> f64 {
279        let target = self.target_lufs();
280        let max_peak = self.max_true_peak_dbtp();
281
282        // Calculate gain needed to reach target loudness
283        let loudness_gain = target - measured_lufs;
284
285        // Calculate maximum gain that won't exceed peak limit
286        let peak_gain = max_peak - measured_peak_dbtp;
287
288        // Use the more conservative (smaller) gain
289        loudness_gain.min(peak_gain)
290    }
291
292    /// Checks if normalization is needed.
293    ///
294    /// # Arguments
295    ///
296    /// * `measured_lufs` - The measured integrated loudness
297    /// * `tolerance` - Tolerance in LU (default: 0.5)
298    #[must_use]
299    pub fn needs_normalization(&self, measured_lufs: f64, tolerance: f64) -> bool {
300        let diff = (measured_lufs - self.target_lufs()).abs();
301        diff > tolerance
302    }
303
304    /// Gets the filter string for loudness normalization.
305    ///
306    /// This generates the filter parameters for audio processing.
307    #[must_use]
308    pub fn get_filter_string(&self) -> String {
309        let target = self.target_lufs();
310        let max_peak = self.max_true_peak_dbtp();
311
312        if self.config.two_pass {
313            format!("loudnorm=I={target}:TP={max_peak}:LRA=11:dual_mono=true")
314        } else {
315            format!("loudnorm=I={target}:TP={max_peak}")
316        }
317    }
318}
319
320/// Measured loudness metrics.
321#[derive(Debug, Clone)]
322pub struct LoudnessMetrics {
323    /// Integrated loudness in LUFS.
324    pub integrated_lufs: f64,
325    /// Loudness range in LU.
326    #[allow(dead_code)]
327    pub loudness_range: f64,
328    /// Maximum true peak in dBTP.
329    pub true_peak_dbtp: f64,
330    /// Maximum momentary loudness in LUFS.
331    #[allow(dead_code)]
332    pub momentary_max: f64,
333    /// Maximum short-term loudness in LUFS.
334    #[allow(dead_code)]
335    pub short_term_max: f64,
336}
337
338impl LoudnessMetrics {
339    /// Checks if the metrics are compliant with a given standard.
340    #[must_use]
341    #[allow(dead_code)]
342    pub fn is_compliant(&self, standard: LoudnessStandard, tolerance: f64) -> bool {
343        let target = standard.target_lufs();
344        let max_peak = standard.max_true_peak_dbtp();
345
346        let loudness_ok = (self.integrated_lufs - target).abs() <= tolerance;
347        let peak_ok = self.true_peak_dbtp <= max_peak;
348
349        loudness_ok && peak_ok
350    }
351
352    /// Gets a compliance report as a string.
353    #[must_use]
354    #[allow(dead_code)]
355    pub fn compliance_report(&self, standard: LoudnessStandard) -> String {
356        let target = standard.target_lufs();
357        let max_peak = standard.max_true_peak_dbtp();
358
359        format!(
360            "Integrated: {:.1} LUFS (target: {:.1} LUFS)\n\
361             True Peak: {:.1} dBTP (max: {:.1} dBTP)\n\
362             Loudness Range: {:.1} LU",
363            self.integrated_lufs, target, self.true_peak_dbtp, max_peak, self.loudness_range
364        )
365    }
366}
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371
372    #[test]
373    fn test_loudness_standard_targets() {
374        assert_eq!(LoudnessStandard::EbuR128.target_lufs(), -23.0);
375        assert_eq!(LoudnessStandard::AtscA85.target_lufs(), -24.0);
376        assert_eq!(LoudnessStandard::Spotify.target_lufs(), -14.0);
377        assert_eq!(LoudnessStandard::YouTube.target_lufs(), -14.0);
378    }
379
380    #[test]
381    fn test_loudness_standard_peaks() {
382        assert_eq!(LoudnessStandard::EbuR128.max_true_peak_dbtp(), -1.0);
383        assert_eq!(LoudnessStandard::AtscA85.max_true_peak_dbtp(), -2.0);
384    }
385
386    #[test]
387    fn test_custom_standard() {
388        let custom = LoudnessStandard::Custom(-18);
389        assert_eq!(custom.target_lufs(), -18.0);
390    }
391
392    #[test]
393    fn test_loudness_target_validation() {
394        let valid = LoudnessTarget::new(-23.0);
395        assert!(valid.validate().is_ok());
396
397        let invalid_positive = LoudnessTarget::new(5.0);
398        assert!(invalid_positive.validate().is_err());
399
400        let invalid_too_low = LoudnessTarget::new(-80.0);
401        assert!(invalid_too_low.validate().is_err());
402    }
403
404    #[test]
405    fn test_normalizer_gain_calculation() {
406        let normalizer = AudioNormalizer::with_standard(LoudnessStandard::EbuR128);
407
408        // Audio at -20 LUFS, peak at -5 dBTP
409        // Target: -23 LUFS, max peak: -1 dBTP
410        let gain = normalizer.calculate_gain(-20.0, -5.0);
411
412        // Loudness gain would be -3.0 dB (to go from -20 to -23)
413        // Peak gain would be +4.0 dB (to go from -5 to -1)
414        // Should use loudness gain (more conservative)
415        assert_eq!(gain, -3.0);
416    }
417
418    #[test]
419    fn test_normalizer_needs_normalization() {
420        let normalizer = AudioNormalizer::with_standard(LoudnessStandard::EbuR128);
421
422        assert!(!normalizer.needs_normalization(-23.0, 0.5)); // Exact match
423        assert!(!normalizer.needs_normalization(-23.3, 0.5)); // Within tolerance
424        assert!(normalizer.needs_normalization(-20.0, 0.5)); // Outside tolerance
425    }
426
427    #[test]
428    fn test_normalizer_filter_string() {
429        let normalizer = AudioNormalizer::with_standard(LoudnessStandard::EbuR128);
430        let filter = normalizer.get_filter_string();
431
432        assert!(filter.contains("loudnorm"));
433        assert!(filter.contains("I=-23"));
434        assert!(filter.contains("TP=-1"));
435    }
436
437    #[test]
438    fn test_loudness_metrics_compliance() {
439        let metrics = LoudnessMetrics {
440            integrated_lufs: -23.2,
441            loudness_range: 8.0,
442            true_peak_dbtp: -1.5,
443            momentary_max: -15.0,
444            short_term_max: -18.0,
445        };
446
447        assert!(metrics.is_compliant(LoudnessStandard::EbuR128, 0.5));
448    }
449
450    #[test]
451    fn test_loudness_metrics_non_compliant() {
452        let metrics = LoudnessMetrics {
453            integrated_lufs: -18.0, // Too loud
454            loudness_range: 8.0,
455            true_peak_dbtp: -1.5,
456            momentary_max: -15.0,
457            short_term_max: -18.0,
458        };
459
460        assert!(!metrics.is_compliant(LoudnessStandard::EbuR128, 0.5));
461    }
462
463    #[test]
464    fn test_normalization_config_builder() {
465        let config = NormalizationConfig::new(LoudnessStandard::Spotify)
466            .with_two_pass(true)
467            .with_linear_only(false)
468            .with_gate_threshold(-50.0);
469
470        assert_eq!(config.standard, LoudnessStandard::Spotify);
471        assert!(config.two_pass);
472        assert!(!config.linear_only);
473        assert_eq!(config.gate_threshold, -50.0);
474    }
475}