1use crate::{Result, TranscodeError};
4use serde::{Deserialize, Serialize};
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
8pub enum LoudnessStandard {
9 EbuR128,
11 AtscA85,
13 AppleMusic,
15 Spotify,
17 YouTube,
19 Amazon,
21 Tidal,
23 Deezer,
25 Custom(i32),
27}
28
29#[derive(Debug, Clone)]
31pub struct LoudnessTarget {
32 pub target_lufs: f64,
34 pub max_true_peak_dbtp: f64,
36 pub loudness_range: Option<(f64, f64)>,
38 pub measure_only: bool,
40}
41
42impl LoudnessStandard {
43 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[must_use]
131 pub fn measure_only(mut self) -> Self {
132 self.measure_only = true;
133 self
134 }
135
136 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#[derive(Debug, Clone)]
174pub struct NormalizationConfig {
175 pub standard: LoudnessStandard,
177 pub target: LoudnessTarget,
179 pub two_pass: bool,
181 pub linear_only: bool,
183 pub gate_threshold: f64,
185}
186
187impl NormalizationConfig {
188 #[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 #[must_use]
202 pub fn with_two_pass(mut self, enable: bool) -> Self {
203 self.two_pass = enable;
204 self
205 }
206
207 #[must_use]
209 pub fn with_linear_only(mut self, enable: bool) -> Self {
210 self.linear_only = enable;
211 self
212 }
213
214 #[must_use]
216 pub fn with_gate_threshold(mut self, threshold: f64) -> Self {
217 self.gate_threshold = threshold;
218 self
219 }
220
221 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
237pub struct AudioNormalizer {
239 config: NormalizationConfig,
240}
241
242impl AudioNormalizer {
243 #[must_use]
245 pub fn new(config: NormalizationConfig) -> Self {
246 Self { config }
247 }
248
249 #[must_use]
251 pub fn with_standard(standard: LoudnessStandard) -> Self {
252 Self::new(NormalizationConfig::new(standard))
253 }
254
255 #[must_use]
257 pub fn target_lufs(&self) -> f64 {
258 self.config.target.target_lufs
259 }
260
261 #[must_use]
263 pub fn max_true_peak_dbtp(&self) -> f64 {
264 self.config.target.max_true_peak_dbtp
265 }
266
267 #[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 let loudness_gain = target - measured_lufs;
284
285 let peak_gain = max_peak - measured_peak_dbtp;
287
288 loudness_gain.min(peak_gain)
290 }
291
292 #[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 #[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#[derive(Debug, Clone)]
322pub struct LoudnessMetrics {
323 pub integrated_lufs: f64,
325 #[allow(dead_code)]
327 pub loudness_range: f64,
328 pub true_peak_dbtp: f64,
330 #[allow(dead_code)]
332 pub momentary_max: f64,
333 #[allow(dead_code)]
335 pub short_term_max: f64,
336}
337
338impl LoudnessMetrics {
339 #[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 #[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 let gain = normalizer.calculate_gain(-20.0, -5.0);
411
412 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)); assert!(!normalizer.needs_normalization(-23.3, 0.5)); assert!(normalizer.needs_normalization(-20.0, 0.5)); }
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, 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}