oximedia_metering/lib.rs
1//! Professional broadcast audio metering for `OxiMedia`.
2//!
3//! This crate provides comprehensive, standards-compliant audio loudness measurement
4//! and metering for broadcast, streaming, and professional audio applications.
5//!
6//! # Supported Standards
7//!
8//! - **ITU-R BS.1770-4** - Algorithms to measure audio programme loudness and true-peak level
9//! - **ITU-R BS.1771** - Requirements for loudness and true-peak indicating meters
10//! - **EBU R128** - Loudness normalisation and permitted maximum level (European standard)
11//! - **ATSC A/85** - Techniques for Establishing and Maintaining Audio Loudness (US standard)
12//! - **Dolby Metadata** - Dialogue Intelligence metadata generation (metadata only, respects IP)
13//!
14//! # Features
15//!
16//! ## Loudness Measurement
17//!
18//! - **Momentary Loudness** - 400ms sliding window with 75% overlap
19//! - **Short-term Loudness** - 3-second sliding window with 75% overlap
20//! - **Integrated Loudness** - Gated program loudness (LKFS/LUFS)
21//! - **Loudness Range (LRA)** - Dynamic range measurement using percentile-based method
22//!
23//! ## True Peak Detection
24//!
25//! - **4x Oversampling** - Detects inter-sample peaks using sinc interpolation
26//! - **Per-channel Tracking** - Individual true peak levels for each channel
27//! - **dBTP Conversion** - True peak in dB relative to full scale
28//!
29//! ## Gating Algorithm
30//!
31//! - **Absolute Gate** - -70 LKFS threshold
32//! - **Relative Gate** - -10 LU below ungated loudness
33//! - **Two-stage Process** - ITU-R BS.1771 compliant gating
34//!
35//! ## Multi-channel Support
36//!
37//! Supports up to 7.1.4 Dolby Atmos layouts with proper channel weighting:
38//! - Mono (1.0)
39//! - Stereo (2.0)
40//! - 5.1 Surround
41//! - 7.1 Surround
42//! - 7.1.4 Dolby Atmos (bed channels)
43//!
44//! ## Compliance Checking
45//!
46//! - EBU R128 compliance (target: -23 LUFS ±1 LU, peak: -1 dBTP)
47//! - ATSC A/85 compliance (target: -24 LKFS ±2 dB, peak: -2 dBTP)
48//! - Streaming platform targets (Spotify, `YouTube`, Apple Music, etc.)
49//!
50//! # Example Usage
51//!
52//! ## Basic Loudness Metering
53//!
54//! ```rust,no_run
55//! use oximedia_metering::{LoudnessMeter, MeterConfig, Standard};
56//!
57//! // Create meter for EBU R128
58//! let config = MeterConfig::new(Standard::EbuR128, 48000.0, 2);
59//! let mut meter = LoudnessMeter::new(config).expect("Failed to create meter");
60//!
61//! // Process audio samples (interleaved f32)
62//! # let audio_samples: &[f32] = &[];
63//! meter.process_f32(audio_samples);
64//!
65//! // Get loudness metrics
66//! let metrics = meter.metrics();
67//! println!("Integrated: {:.1} LUFS", metrics.integrated_lufs);
68//! println!("LRA: {:.1} LU", metrics.loudness_range);
69//! println!("True Peak: {:.1} dBTP", metrics.true_peak_dbtp);
70//!
71//! // Check compliance
72//! let compliance = meter.check_compliance();
73//! if compliance.is_compliant() {
74//! println!("Audio is compliant with {}", compliance.standard_name());
75//! }
76//!
77//! // Generate detailed report
78//! let report = meter.generate_report();
79//! println!("{}", report);
80//! ```
81//!
82//! ## Peak Metering
83//!
84//! ```rust,no_run
85//! use oximedia_metering::{PeakMeter, PeakMeterType};
86//!
87//! // Create a VU meter for stereo audio
88//! let mut vu_meter = PeakMeter::new(
89//! PeakMeterType::Vu,
90//! 48000.0,
91//! 2,
92//! 2.0 // 2 second peak hold
93//! ).expect("Failed to create VU meter");
94//!
95//! # let audio_samples: &[f64] = &[];
96//! vu_meter.process_interleaved(audio_samples);
97//!
98//! let peaks = vu_meter.peak_dbfs();
99//! println!("L: {:.1} dBFS, R: {:.1} dBFS", peaks[0], peaks[1]);
100//!
101//! // Create an RMS meter with 300ms integration
102//! let mut rms_meter = PeakMeter::new(
103//! PeakMeterType::Rms(0.3),
104//! 48000.0,
105//! 2,
106//! 0.0
107//! ).expect("Failed to create RMS meter");
108//! ```
109//!
110//! ## K-System Metering
111//!
112//! ```rust,no_run
113//! use oximedia_metering::{KSystemMeter, KSystemType};
114//!
115//! // Create K-14 meter (mastering standard)
116//! let mut k_meter = KSystemMeter::new(
117//! KSystemType::K14,
118//! 48000.0,
119//! 2
120//! ).expect("Failed to create K-meter");
121//!
122//! # let audio_samples: &[f64] = &[];
123//! k_meter.process_interleaved(audio_samples);
124//!
125//! // Get levels relative to K-14 reference
126//! let rms_levels = k_meter.rms_relative_db();
127//! println!("RMS relative to K-14: L={:.1} dB, R={:.1} dB",
128//! rms_levels[0], rms_levels[1]);
129//!
130//! if k_meter.is_overload() {
131//! println!("Warning: Headroom exceeded!");
132//! }
133//! ```
134//!
135//! ## Phase Analysis
136//!
137//! ```rust,no_run
138//! use oximedia_metering::{PhaseCorrelationMeter, StereoWidthAnalyzer};
139//!
140//! // Create phase correlation meter
141//! let mut phase_meter = PhaseCorrelationMeter::new(48000.0, 0.4)
142//! .expect("Failed to create phase meter");
143//!
144//! # let audio_samples: &[f64] = &[];
145//! phase_meter.process_interleaved(audio_samples);
146//!
147//! let correlation = phase_meter.correlation();
148//! println!("Phase correlation: {:.2}", correlation);
149//!
150//! if phase_meter.has_phase_issues() {
151//! println!("Warning: Phase cancellation detected!");
152//! }
153//!
154//! // Stereo width analysis
155//! let mut width_analyzer = StereoWidthAnalyzer::new(48000.0)
156//! .expect("Failed to create width analyzer");
157//!
158//! width_analyzer.process_interleaved(audio_samples);
159//! println!("Stereo width: {:.0}%", width_analyzer.width_percentage());
160//! ```
161//!
162//! ## Spectrum Analysis
163//!
164//! ```rust,no_run
165//! use oximedia_metering::{SpectrumAnalyzer, WindowFunction, WeightingCurve};
166//!
167//! // Create FFT-based spectrum analyzer
168//! let mut spectrum = SpectrumAnalyzer::new(
169//! 48000.0,
170//! 2048,
171//! WindowFunction::Hann,
172//! WeightingCurve::A,
173//! 1.0 // 1 second peak hold
174//! ).expect("Failed to create spectrum analyzer");
175//!
176//! # let audio_samples: &[f64] = &[];
177//! spectrum.process(audio_samples);
178//!
179//! let spectrum_db = spectrum.spectrum_db();
180//! for (i, &magnitude) in spectrum_db.iter().take(10).enumerate() {
181//! let freq = spectrum.bin_frequency(i);
182//! println!("{:.0} Hz: {:.1} dB", freq, magnitude);
183//! }
184//! ```
185//!
186//! ## Video Metering
187//!
188//! ```rust,no_run
189//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
190//! use oximedia_metering::{LuminanceMeter, GamutMeter, ColorGamut, QualityAnalyzer, Frame2D};
191//!
192//! // Luminance metering for HDR content
193//! let mut lum_meter = LuminanceMeter::new(1920, 1080, 1000.0, 256)
194//! .expect("Failed to create luminance meter");
195//!
196//! # let luminance_frame = Frame2D::zeros(1080, 1920);
197//! lum_meter.process(&luminance_frame)?;
198//!
199//! println!("Peak: {:.1} nits", lum_meter.peak_nits());
200//! println!("Average: {:.1} nits", lum_meter.average_nits());
201//! println!("Dynamic range: {:.1} stops", lum_meter.dynamic_range_stops());
202//!
203//! if lum_meter.is_hdr10() {
204//! println!("HDR10 content detected");
205//! }
206//!
207//! // Color gamut analysis
208//! let mut gamut_meter = GamutMeter::new(1920, 1080, ColorGamut::Rec2020)
209//! .expect("Failed to create gamut meter");
210//!
211//! # let r_channel = Frame2D::zeros(1080, 1920);
212//! # let g_channel = Frame2D::zeros(1080, 1920);
213//! # let b_channel = Frame2D::zeros(1080, 1920);
214//! gamut_meter.process(&r_channel, &g_channel, &b_channel)?;
215//!
216//! println!("Rec.2020 coverage: {:.1}%", gamut_meter.gamut_coverage_percentage());
217//! println!("Max saturation: {:.2}", gamut_meter.max_saturation());
218//!
219//! // Video quality metrics (PSNR, SSIM)
220//! let quality = QualityAnalyzer::new(1920, 1080, 1.0)
221//! .expect("Failed to create quality analyzer");
222//!
223//! # let reference_frame = Frame2D::zeros(1080, 1920);
224//! # let distorted_frame = Frame2D::zeros(1080, 1920);
225//! let metrics = quality.analyze(&reference_frame, &distorted_frame)?;
226//!
227//! println!("PSNR: {:.2} dB", metrics.psnr);
228//! println!("SSIM: {:.4}", metrics.ssim);
229//! println!("Quality: {}", metrics.rating());
230//! # Ok(())
231//! # }
232//! ```
233//!
234//! ## Meter Rendering
235//!
236//! ```rust,no_run
237//! use oximedia_metering::{BarMeterConfig, BarMeterData, ColorGradient, Orientation};
238//!
239//! // Configure a vertical bar meter
240//! let config = BarMeterConfig {
241//! orientation: Orientation::Vertical,
242//! width: 30,
243//! height: 200,
244//! min_value: -60.0,
245//! max_value: 0.0,
246//! gradient: ColorGradient::traffic_light(),
247//! show_peak_hold: true,
248//! show_scale: true,
249//! ..Default::default()
250//! };
251//!
252//! // Create meter data from dBFS values
253//! let meter_data = BarMeterData::from_dbfs(
254//! -12.0, // Current level
255//! -6.0, // Peak hold
256//! -60.0, // Min range
257//! 0.0 // Max range
258//! );
259//!
260//! if meter_data.is_clipping {
261//! println!("Clipping detected!");
262//! }
263//!
264//! // Get color for current level
265//! let color = config.gradient.color_at(meter_data.level);
266//! println!("Meter color: RGB({}, {}, {})", color.r, color.g, color.b);
267//! ```
268//!
269//! # Technical Implementation
270//!
271//! ## K-weighting Filter
272//!
273//! The K-weighting filter chain implements ITU-R BS.1770-4 specification:
274//! - **Stage 1**: High-pass filter at 78.5 Hz (head diffraction modeling)
275//! - **Stage 2**: High-shelf filter for revised low-frequency B-weighting (RLB)
276//!
277//! Both filters are implemented as second-order IIR biquad filters with
278//! precise coefficients calculated for the given sample rate.
279//!
280//! ## Block Processing
281//!
282//! Audio is processed in overlapping blocks:
283//! - **Block size**: 100ms (400ms blocks for momentary, 3000ms for short-term)
284//! - **Overlap**: 75% (blocks advance by 25% of their duration)
285//! - **Gating**: Applied on 400ms blocks with absolute (-70 LKFS) and relative (-10 LU) gates
286//!
287//! ## True Peak Detection
288//!
289//! Uses 4x oversampling with windowed sinc interpolation:
290//! - Lanczos-windowed sinc function (a=3)
291//! - Linear-phase FIR resampling
292//! - Per-sample peak tracking
293//!
294//! # Performance
295//!
296//! - **Real-time capable**: Processes audio faster than real-time on modern CPUs
297//! - **Memory efficient**: Circular buffers for sliding windows
298//! - **Zero-copy where possible**: Processes interleaved or planar audio in-place when possible
299//!
300//! # Standards References
301//!
302//! - ITU-R BS.1770-4 (10/2015): "Algorithms to measure audio programme loudness and true-peak audio level"
303//! - ITU-R BS.1771 (2006): "Requirements for loudness and true-peak indicating meters"
304//! - EBU R 128 (2020): "Loudness normalisation and permitted maximum level of audio signals"
305//! - ATSC A/85:2013: "Techniques for Establishing and Maintaining Audio Loudness for Digital Television"
306
307#![forbid(unsafe_code)]
308#![warn(missing_docs)]
309#![allow(clippy::module_name_repetitions)]
310#![allow(clippy::must_use_candidate)]
311#![allow(clippy::similar_names)]
312#![allow(clippy::unreadable_literal)]
313#![allow(clippy::cast_precision_loss)]
314#![allow(clippy::many_single_char_names)]
315#![allow(clippy::if_same_then_else)]
316#![allow(clippy::unused_self)]
317#![allow(clippy::cast_possible_truncation)]
318#![allow(clippy::cast_sign_loss)]
319#![allow(clippy::doc_markdown)]
320#![allow(clippy::fn_params_excessive_bools)]
321#![allow(clippy::let_and_return)]
322#![allow(clippy::match_same_arms)]
323#![allow(clippy::missing_errors_doc)]
324#![allow(clippy::struct_excessive_bools)]
325#![allow(clippy::format_push_string)]
326#![allow(clippy::trivially_copy_pass_by_ref)]
327#![allow(clippy::missing_panics_doc)]
328#![allow(dead_code)]
329#![allow(
330 clippy::float_cmp,
331 clippy::too_many_lines,
332 clippy::return_self_not_must_use
333)]
334
335pub mod atsc;
336pub mod ballistics;
337pub mod bs2051_weights;
338pub mod bs2132;
339pub mod clip_counter;
340pub mod correlation;
341pub mod dr_meter;
342pub mod dynamics;
343pub mod ebu;
344pub mod ebu_r128_impl;
345pub mod filters;
346pub mod gating;
347pub mod k_weighting;
348pub mod leq;
349pub mod lkfs;
350pub mod loudness_gate;
351pub mod loudness_history;
352pub mod m_s_meter;
353pub mod meter_type_config;
354pub mod ms_ssim;
355pub mod octave_bands;
356pub mod peak;
357pub mod phase;
358pub mod phase_scope;
359pub mod ppm;
360pub mod range;
361pub mod render;
362pub mod report;
363pub mod rms_envelope;
364pub mod silence_detect;
365pub mod spectral_balance;
366pub mod spectral_energy;
367pub mod spectrum;
368pub mod spectrum_bands;
369pub mod true_peak_meter;
370pub mod truepeak;
371pub mod video_color;
372pub mod video_luminance;
373pub mod video_quality;
374pub mod vmaf_estimate;
375pub mod vmaf_features;
376pub mod vu_meter;
377
378/// Backward-compatibility aliases for merged modules.
379pub use correlation as correlation_meter;
380/// Backward-compatibility alias: items from `dynamic_range_meter` now in [`dynamics`].
381pub use dynamics as dynamic_range_meter;
382/// Backward-compatibility alias: items from `peak_meter` now in [`peak`].
383pub use peak as peak_meter;
384/// Backward-compatibility alias: items from `phase_analysis` now in [`phase`].
385pub use phase as phase_analysis;
386/// Backward-compatibility alias: items from `true_peak` now in [`truepeak`].
387pub use truepeak as true_peak;
388
389// Wave 12 modules
390pub mod crest_factor;
391pub mod k_weighted;
392pub mod meter_bridge;
393
394// Wave 15 modules
395pub mod loudness_trend;
396pub mod noise_floor;
397pub mod stereo_balance;
398
399// Wave 16 modules
400pub mod k_weight_simd;
401pub mod temporal_noise;
402
403use oximedia_core::types::SampleFormat;
404use thiserror::Error;
405
406pub use atsc::{AtscA85Compliance, AtscA85Meter};
407pub use ballistics::{BallisticProcessor, BallisticType, MultiChannelBallistics};
408pub use correlation::{
409 CorrelationMeter, FrequencyBand, Goniometer as CorrelationGoniometer,
410 GoniometerPoint as CorrelationGoniometerPoint, MultibandMeter, PhaseRelationship,
411};
412pub use dynamics::{DynamicRangeMeter, PlrMeter};
413pub use ebu::{EbuR128Compliance, EbuR128Meter};
414pub use filters::{KWeightFilter, KWeightFilterBank};
415pub use gating::{GatingProcessor, GatingResult};
416pub use lkfs::{LkfsCalculator, LufsValue};
417pub use peak::{
418 dbfs_to_linear, linear_to_dbfs, KSystemMeter, KSystemType, PeakMeter, PeakMeterType,
419};
420pub use phase::{Goniometer, GoniometerPoint, PhaseCorrelationMeter, StereoWidthAnalyzer};
421pub use range::{LoudnessRange, LraCalculator};
422pub use render::{
423 colors, generate_db_scale, BarMeterConfig, BarMeterData, CircularMeterConfig, Color,
424 ColorGradient, Orientation, ScaleMark, ScaleType,
425};
426pub use report::{ComplianceReport, LoudnessReport, MeteringReport};
427pub use spectrum::{
428 CachedSpectrumAnalyzer, OctaveBand, OctaveBandAnalyzer, SpectrumAnalyzer, WeightingCurve,
429 WindowFunction,
430};
431pub use truepeak::{TruePeak, TruePeakDetector};
432pub use video_color::{
433 ColorGamut, ColorTemperatureMeter, GamutMeter, HsvColor, RgbColor, SaturationMeter,
434};
435pub use video_luminance::{BlackWhiteLevelMeter, LuminanceMeter};
436pub use video_quality::{
437 BlockinessDetector, Frame2D, PsnrCalculator, QualityAnalyzer, QualityMetrics, SsimCalculator,
438};
439
440/// Metering error types.
441#[derive(Error, Debug)]
442pub enum MeteringError {
443 /// Invalid configuration.
444 #[error("Invalid configuration: {0}")]
445 InvalidConfig(String),
446
447 /// Insufficient data for measurement.
448 #[error("Insufficient data: {0}")]
449 InsufficientData(String),
450
451 /// Sample format not supported.
452 #[error("Unsupported sample format: {0:?}")]
453 UnsupportedFormat(SampleFormat),
454
455 /// Channel configuration error.
456 #[error("Channel error: {0}")]
457 ChannelError(String),
458
459 /// Calculation error.
460 #[error("Calculation error: {0}")]
461 CalculationError(String),
462}
463
464/// Metering result type.
465pub type MeteringResult<T> = std::result::Result<T, MeteringError>;
466
467/// Broadcast loudness standard.
468#[derive(Clone, Copy, Debug, PartialEq, Default)]
469pub enum Standard {
470 /// EBU R128 (European Broadcasting Union).
471 ///
472 /// Target: -23 LUFS ±1 LU
473 /// Max True Peak: -1.0 dBTP
474 #[default]
475 EbuR128,
476
477 /// ATSC A/85 (Advanced Television Systems Committee - US).
478 ///
479 /// Target: -24 LKFS ±2 dB
480 /// Max True Peak: -2.0 dBTP
481 AtscA85,
482
483 /// Spotify streaming platform.
484 ///
485 /// Target: -14 LUFS
486 /// Max True Peak: -1.0 dBTP
487 Spotify,
488
489 /// `YouTube` streaming platform.
490 ///
491 /// Target: -14 LUFS
492 /// Max True Peak: -1.0 dBTP
493 YouTube,
494
495 /// Apple Music streaming platform.
496 ///
497 /// Target: -16 LUFS
498 /// Max True Peak: -1.0 dBTP
499 AppleMusic,
500
501 /// Netflix streaming platform.
502 ///
503 /// Target: -27 LUFS
504 /// Max True Peak: -2.0 dBTP
505 Netflix,
506
507 /// Amazon Prime Video.
508 ///
509 /// Target: -24 LUFS
510 /// Max True Peak: -2.0 dBTP
511 AmazonPrime,
512
513 /// Tidal HiFi streaming platform.
514 ///
515 /// Target: -14 LUFS
516 /// Max True Peak: -1.0 dBTP
517 TidalHiFi,
518
519 /// Amazon Music HD streaming platform.
520 ///
521 /// Target: -14 LUFS
522 /// Max True Peak: -1.0 dBTP
523 AmazonMusicHd,
524
525 /// Custom target loudness.
526 ///
527 /// Specify your own target in LUFS and max true peak in dBTP.
528 Custom {
529 /// Target loudness in LUFS.
530 target_lufs: f64,
531 /// Maximum true peak in dBTP.
532 max_peak_dbtp: f64,
533 /// Tolerance in LU.
534 tolerance_lu: f64,
535 },
536}
537
538impl Standard {
539 /// Get the target loudness in LUFS for this standard.
540 pub fn target_lufs(&self) -> f64 {
541 match self {
542 Self::EbuR128 => -23.0,
543 Self::AtscA85 | Self::AmazonPrime => -24.0,
544 Self::Spotify | Self::YouTube | Self::TidalHiFi | Self::AmazonMusicHd => -14.0,
545 Self::AppleMusic => -16.0,
546 Self::Netflix => -27.0,
547 Self::Custom { target_lufs, .. } => *target_lufs,
548 }
549 }
550
551 /// Get the maximum true peak in dBTP for this standard.
552 pub fn max_true_peak_dbtp(&self) -> f64 {
553 match self {
554 Self::EbuR128
555 | Self::Spotify
556 | Self::YouTube
557 | Self::AppleMusic
558 | Self::TidalHiFi
559 | Self::AmazonMusicHd => -1.0,
560 Self::AtscA85 | Self::Netflix | Self::AmazonPrime => -2.0,
561 Self::Custom { max_peak_dbtp, .. } => *max_peak_dbtp,
562 }
563 }
564
565 /// Get the tolerance in LU for this standard.
566 pub fn tolerance_lu(&self) -> f64 {
567 match self {
568 Self::EbuR128
569 | Self::Spotify
570 | Self::YouTube
571 | Self::AppleMusic
572 | Self::TidalHiFi
573 | Self::AmazonMusicHd => 1.0,
574 Self::AtscA85 | Self::Netflix | Self::AmazonPrime => 2.0,
575 Self::Custom { tolerance_lu, .. } => *tolerance_lu,
576 }
577 }
578
579 /// Get the standard name as a string.
580 pub fn name(&self) -> &str {
581 match self {
582 Self::EbuR128 => "EBU R128",
583 Self::AtscA85 => "ATSC A/85",
584 Self::Spotify => "Spotify",
585 Self::YouTube => "YouTube",
586 Self::AppleMusic => "Apple Music",
587 Self::Netflix => "Netflix",
588 Self::AmazonPrime => "Amazon Prime Video",
589 Self::TidalHiFi => "Tidal HiFi",
590 Self::AmazonMusicHd => "Amazon Music HD",
591 Self::Custom { .. } => "Custom",
592 }
593 }
594}
595
596/// Meter configuration.
597#[derive(Clone, Debug)]
598#[allow(clippy::struct_excessive_bools)]
599pub struct MeterConfig {
600 /// Broadcast standard to measure against.
601 pub standard: Standard,
602 /// Sample rate in Hz.
603 pub sample_rate: f64,
604 /// Number of audio channels.
605 pub channels: usize,
606 /// Enable true peak detection (4x oversampling).
607 pub enable_true_peak: bool,
608 /// Enable loudness range (LRA) calculation.
609 pub enable_lra: bool,
610 /// Enable momentary loudness tracking.
611 pub enable_momentary: bool,
612 /// Enable short-term loudness tracking.
613 pub enable_short_term: bool,
614 /// Enable integrated loudness (gated program loudness).
615 pub enable_integrated: bool,
616}
617
618impl MeterConfig {
619 /// Create a new meter configuration.
620 ///
621 /// # Arguments
622 ///
623 /// * `standard` - Broadcast standard
624 /// * `sample_rate` - Sample rate in Hz
625 /// * `channels` - Number of channels
626 pub fn new(standard: Standard, sample_rate: f64, channels: usize) -> Self {
627 Self {
628 standard,
629 sample_rate,
630 channels,
631 enable_true_peak: true,
632 enable_lra: true,
633 enable_momentary: true,
634 enable_short_term: true,
635 enable_integrated: true,
636 }
637 }
638
639 /// Create a minimal configuration (integrated loudness and true peak only).
640 pub fn minimal(standard: Standard, sample_rate: f64, channels: usize) -> Self {
641 Self {
642 standard,
643 sample_rate,
644 channels,
645 enable_true_peak: true,
646 enable_lra: false,
647 enable_momentary: false,
648 enable_short_term: false,
649 enable_integrated: true,
650 }
651 }
652
653 /// Validate the configuration.
654 ///
655 /// # Errors
656 ///
657 /// Returns `MeteringError::InvalidConfig` if any configuration parameters are out of valid range.
658 pub fn validate(&self) -> MeteringResult<()> {
659 if self.sample_rate < 8000.0 || self.sample_rate > 192_000.0 {
660 return Err(MeteringError::InvalidConfig(format!(
661 "Sample rate {} Hz is out of valid range (8000-192000 Hz)",
662 self.sample_rate
663 )));
664 }
665
666 if self.channels == 0 || self.channels > 16 {
667 return Err(MeteringError::InvalidConfig(format!(
668 "Channel count {} is out of valid range (1-16)",
669 self.channels
670 )));
671 }
672
673 if !self.enable_integrated && !self.enable_momentary && !self.enable_short_term {
674 return Err(MeteringError::InvalidConfig(
675 "At least one loudness measurement must be enabled".to_string(),
676 ));
677 }
678
679 Ok(())
680 }
681}
682
683/// Channel configuration for multi-channel audio.
684#[derive(Clone, Copy, Debug, PartialEq)]
685pub enum ChannelLayout {
686 /// Mono (1.0).
687 Mono,
688 /// Stereo (2.0).
689 Stereo,
690 /// 5.1 Surround (L, R, C, LFE, Ls, Rs).
691 Surround51,
692 /// 7.1 Surround (L, R, C, LFE, Ls, Rs, Lrs, Rrs).
693 Surround71,
694 /// 7.1.4 Dolby Atmos bed (L, R, C, LFE, Ls, Rs, Lrs, Rrs, Ltf, Rtf, Ltb, Rtb).
695 Atmos714,
696 /// NHK 22.2 immersive audio layout per ITU-R BS.2051-3.
697 ///
698 /// 24 speakers arranged in 3 layers:
699 /// - Top layer (9 ch): TpFL, TpFR, TpFC, TpC, TpBL, TpBR, TpSiL, TpSiR, TpBC
700 /// - Middle layer (10 ch): FL, FR, FC, LFE1, BL, BR, FLc, FRc, BC, LFE2
701 /// - Bottom layer (4 ch): BtFL, BtFR, BtFC, BtBC
702 /// - Plus 1 overhead centre (CH): TpFC (already in top)
703 ///
704 /// Channel order follows ITU-R BS.2051-3 Table 1 (22.2 layout).
705 Nhk222,
706 /// Custom channel configuration.
707 Custom(usize),
708}
709
710impl ChannelLayout {
711 /// Get the number of channels.
712 pub fn channel_count(&self) -> usize {
713 match self {
714 Self::Mono => 1,
715 Self::Stereo => 2,
716 Self::Surround51 => 6,
717 Self::Surround71 => 8,
718 Self::Atmos714 => 12,
719 Self::Nhk222 => 24,
720 Self::Custom(n) => *n,
721 }
722 }
723
724 /// Get ITU-R BS.1770-4 channel weights for this layout.
725 ///
726 /// Returns a vector of weights to apply to each channel during loudness calculation.
727 /// For the NHK 22.2 layout the weights follow ITU-R BS.2051-3 Section 6 (reproduced
728 /// below). The standard specifies that LFE channels contribute zero power and that
729 /// surround/rear/overhead channels use a +1.5 dB gain (linear ≈ 1.189) relative to
730 /// front centre and left/right channels.
731 pub fn channel_weights(&self) -> Vec<f64> {
732 match self {
733 Self::Mono => vec![1.0],
734 Self::Stereo => vec![1.0, 1.0],
735 Self::Surround51 => {
736 // L, R, C, LFE, Ls, Rs
737 vec![1.0, 1.0, 1.0, 0.0, 1.41, 1.41]
738 }
739 Self::Surround71 => {
740 // L, R, C, LFE, Ls, Rs, Lrs, Rrs
741 vec![1.0, 1.0, 1.0, 0.0, 1.41, 1.41, 1.41, 1.41]
742 }
743 Self::Atmos714 => {
744 // L, R, C, LFE, Ls, Rs, Lrs, Rrs, Ltf, Rtf, Ltb, Rtb
745 vec![
746 1.0, 1.0, 1.0, 0.0, 1.41, 1.41, 1.41, 1.41, 1.41, 1.41, 1.41, 1.41,
747 ]
748 }
749 Self::Nhk222 => {
750 // ITU-R BS.2051-3 NHK 22.2 channel weights.
751 //
752 // 24 channels in order (ITU-R BS.2051-3 Table 1):
753 //
754 // Top layer (9 channels):
755 // 0:TpFL 1:TpFR 2:TpFC 3:TpC 4:TpBL 5:TpBR 6:TpSiL 7:TpSiR 8:TpBC
756 // Middle layer (10 channels):
757 // 9:FL 10:FR 11:FC 12:LFE1 13:BL 14:BR 15:FLc 16:FRc 17:BC 18:LFE2
758 // Bottom layer (4 channels):
759 // 19:BtFL 20:BtFR 21:BtFC 22:BtBC
760 // Plus one overhead:
761 // 23:CH (overhead centre, equivalent to +1.5 dB weight)
762 //
763 // Weight rules from BS.2051-3 §6:
764 // - Front centre (FC): 1.0 (0 dB reference)
765 // - Left/Right front (FL, FR, FLc, FRc): 1.0
766 // - Surround/Rear (BL, BR, BC): 1.189 (~+1.5 dB)
767 // - Top layer: 1.189 (~+1.5 dB)
768 // - Bottom layer: 1.189 (~+1.5 dB)
769 // - Overhead centre (CH): 1.189 (~+1.5 dB)
770 // - LFE1, LFE2: 0.0 (excluded per BS.1770-4 §3.4)
771
772 // √2 ≈ 1.41213, +1.5 dB ≈ 1.18850
773 const W_SURROUND: f64 = 1.188_502_227_4; // 10^(1.5/20)
774 const W_FRONT: f64 = 1.0;
775 const W_LFE: f64 = 0.0;
776
777 vec![
778 // Top layer (9 ch)
779 W_SURROUND, // TpFL
780 W_SURROUND, // TpFR
781 W_SURROUND, // TpFC
782 W_SURROUND, // TpC
783 W_SURROUND, // TpBL
784 W_SURROUND, // TpBR
785 W_SURROUND, // TpSiL
786 W_SURROUND, // TpSiR
787 W_SURROUND, // TpBC
788 // Middle layer (10 ch)
789 W_FRONT, // FL
790 W_FRONT, // FR
791 W_FRONT, // FC
792 W_LFE, // LFE1
793 W_SURROUND, // BL
794 W_SURROUND, // BR
795 W_FRONT, // FLc
796 W_FRONT, // FRc
797 W_SURROUND, // BC
798 W_LFE, // LFE2
799 // Bottom layer (4 ch)
800 W_SURROUND, // BtFL
801 W_SURROUND, // BtFR
802 W_SURROUND, // BtFC
803 W_SURROUND, // BtBC
804 // Overhead centre
805 W_SURROUND, // CH
806 ]
807 }
808 Self::Custom(n) => vec![1.0; *n],
809 }
810 }
811
812 /// Create from channel count.
813 pub fn from_channel_count(count: usize) -> Self {
814 match count {
815 1 => Self::Mono,
816 2 => Self::Stereo,
817 6 => Self::Surround51,
818 8 => Self::Surround71,
819 12 => Self::Atmos714,
820 24 => Self::Nhk222,
821 n => Self::Custom(n),
822 }
823 }
824}
825
826/// Loudness measurement metrics.
827#[derive(Clone, Debug, Default)]
828pub struct LoudnessMetrics {
829 /// Momentary loudness in LUFS (400ms window).
830 pub momentary_lufs: f64,
831 /// Short-term loudness in LUFS (3s window).
832 pub short_term_lufs: f64,
833 /// Integrated loudness in LUFS (gated program loudness).
834 pub integrated_lufs: f64,
835 /// Loudness range in LU.
836 pub loudness_range: f64,
837 /// True peak in dBTP (maximum across all channels).
838 pub true_peak_dbtp: f64,
839 /// True peak in linear scale.
840 pub true_peak_linear: f64,
841 /// Maximum momentary loudness seen.
842 pub max_momentary: f64,
843 /// Maximum short-term loudness seen.
844 pub max_short_term: f64,
845 /// Per-channel true peaks in dBTP.
846 pub channel_peaks_dbtp: Vec<f64>,
847}
848
849/// Main loudness meter.
850///
851/// This is the primary interface for loudness measurement. It combines all
852/// measurement algorithms (LKFS, gating, true peak, LRA) into a single meter.
853pub struct LoudnessMeter {
854 config: MeterConfig,
855 lkfs_calculator: LkfsCalculator,
856 gating_processor: GatingProcessor,
857 true_peak_detector: Option<TruePeakDetector>,
858 lra_calculator: Option<LraCalculator>,
859 filter_bank: KWeightFilterBank,
860 channel_layout: ChannelLayout,
861 samples_processed: usize,
862}
863
864impl LoudnessMeter {
865 /// Create a new loudness meter.
866 ///
867 /// # Arguments
868 ///
869 /// * `config` - Meter configuration
870 ///
871 /// # Errors
872 ///
873 /// Returns error if configuration is invalid.
874 pub fn new(config: MeterConfig) -> MeteringResult<Self> {
875 config.validate()?;
876
877 let channel_layout = ChannelLayout::from_channel_count(config.channels);
878 let filter_bank = KWeightFilterBank::new(config.channels, config.sample_rate);
879 let lkfs_calculator = LkfsCalculator::new(config.sample_rate, config.channels);
880 let gating_processor = GatingProcessor::new(config.sample_rate, config.channels);
881
882 let true_peak_detector = if config.enable_true_peak {
883 Some(TruePeakDetector::new(config.sample_rate, config.channels))
884 } else {
885 None
886 };
887
888 let lra_calculator = if config.enable_lra {
889 Some(LraCalculator::new())
890 } else {
891 None
892 };
893
894 Ok(Self {
895 config,
896 lkfs_calculator,
897 gating_processor,
898 true_peak_detector,
899 lra_calculator,
900 filter_bank,
901 channel_layout,
902 samples_processed: 0,
903 })
904 }
905
906 /// Process f32 audio samples (interleaved).
907 ///
908 /// # Arguments
909 ///
910 /// * `samples` - Interleaved audio samples normalized to -1.0 to 1.0
911 pub fn process_f32(&mut self, samples: &[f32]) {
912 let f64_samples: Vec<f64> = samples.iter().map(|&s| f64::from(s)).collect();
913 self.process_f64(&f64_samples);
914 }
915
916 /// Process f64 audio samples (interleaved).
917 ///
918 /// # Arguments
919 ///
920 /// * `samples` - Interleaved audio samples normalized to -1.0 to 1.0
921 pub fn process_f64(&mut self, samples: &[f64]) {
922 if samples.is_empty() {
923 return;
924 }
925
926 // Apply K-weighting filter
927 let mut filtered = vec![0.0; samples.len()];
928 self.filter_bank
929 .process_interleaved(samples, self.config.channels, &mut filtered);
930
931 // Process LKFS calculation
932 self.lkfs_calculator.process_interleaved(&filtered);
933
934 // Process gating (for integrated loudness)
935 self.gating_processor.process_interleaved(&filtered);
936
937 // Process true peak (on original unfiltered samples)
938 if let Some(ref mut detector) = self.true_peak_detector {
939 detector.process_interleaved(samples);
940 }
941
942 self.samples_processed += samples.len() / self.config.channels;
943 }
944
945 /// Get current loudness metrics.
946 pub fn metrics(&mut self) -> LoudnessMetrics {
947 let momentary = if self.config.enable_momentary {
948 self.lkfs_calculator.momentary_loudness()
949 } else {
950 f64::NEG_INFINITY
951 };
952
953 let short_term = if self.config.enable_short_term {
954 self.lkfs_calculator.short_term_loudness()
955 } else {
956 f64::NEG_INFINITY
957 };
958
959 let integrated = if self.config.enable_integrated {
960 self.gating_processor.integrated_loudness()
961 } else {
962 f64::NEG_INFINITY
963 };
964
965 let loudness_range = if let Some(ref mut lra_calc) = self.lra_calculator {
966 let blocks = self.gating_processor.get_blocks_for_lra();
967 lra_calc.calculate(&blocks)
968 } else {
969 0.0
970 };
971
972 let (true_peak_dbtp, true_peak_linear, channel_peaks_dbtp) =
973 if let Some(ref detector) = self.true_peak_detector {
974 let peaks = detector.channel_peaks_dbtp();
975 let max_peak = detector.true_peak_dbtp();
976 let max_linear = detector.true_peak_linear();
977 (max_peak, max_linear, peaks)
978 } else {
979 (f64::NEG_INFINITY, 0.0, vec![])
980 };
981
982 LoudnessMetrics {
983 momentary_lufs: momentary,
984 short_term_lufs: short_term,
985 integrated_lufs: integrated,
986 loudness_range,
987 true_peak_dbtp,
988 true_peak_linear,
989 max_momentary: self.lkfs_calculator.max_momentary(),
990 max_short_term: self.lkfs_calculator.max_short_term(),
991 channel_peaks_dbtp,
992 }
993 }
994
995 /// Check compliance with the configured standard.
996 pub fn check_compliance(&mut self) -> ComplianceResult {
997 let metrics = self.metrics();
998 let standard = &self.config.standard;
999
1000 let target = standard.target_lufs();
1001 let tolerance = standard.tolerance_lu();
1002 let max_peak = standard.max_true_peak_dbtp();
1003
1004 let loudness_compliant = if metrics.integrated_lufs.is_finite() {
1005 metrics.integrated_lufs >= target - tolerance
1006 && metrics.integrated_lufs <= target + tolerance
1007 } else {
1008 false
1009 };
1010
1011 let peak_compliant = metrics.true_peak_dbtp <= max_peak;
1012
1013 let lra_acceptable = metrics.loudness_range >= 1.0 && metrics.loudness_range <= 30.0;
1014
1015 ComplianceResult {
1016 standard: *standard,
1017 loudness_compliant,
1018 peak_compliant,
1019 lra_acceptable,
1020 integrated_lufs: metrics.integrated_lufs,
1021 true_peak_dbtp: metrics.true_peak_dbtp,
1022 loudness_range: metrics.loudness_range,
1023 target_lufs: target,
1024 max_peak_dbtp: max_peak,
1025 deviation_lu: if metrics.integrated_lufs.is_finite() {
1026 metrics.integrated_lufs - target
1027 } else {
1028 0.0
1029 },
1030 }
1031 }
1032
1033 /// Generate a detailed loudness report.
1034 #[allow(clippy::cast_precision_loss)]
1035 pub fn generate_report(&mut self) -> LoudnessReport {
1036 let metrics = self.metrics();
1037 let compliance = self.check_compliance();
1038 let duration_seconds = self.samples_processed as f64 / self.config.sample_rate;
1039
1040 LoudnessReport::new(metrics, compliance, duration_seconds)
1041 }
1042
1043 /// Reset the meter to initial state.
1044 pub fn reset(&mut self) {
1045 self.lkfs_calculator.reset();
1046 self.gating_processor.reset();
1047 if let Some(ref mut detector) = self.true_peak_detector {
1048 detector.reset();
1049 }
1050 if let Some(ref mut lra_calc) = self.lra_calculator {
1051 lra_calc.reset();
1052 }
1053 self.filter_bank.reset();
1054 self.samples_processed = 0;
1055 }
1056
1057 /// Get the meter configuration.
1058 pub fn config(&self) -> &MeterConfig {
1059 &self.config
1060 }
1061
1062 /// Get the number of samples processed (per channel).
1063 pub fn samples_processed(&self) -> usize {
1064 self.samples_processed
1065 }
1066
1067 /// Get the duration of processed audio in seconds.
1068 #[allow(clippy::cast_precision_loss)]
1069 pub fn duration_seconds(&self) -> f64 {
1070 self.samples_processed as f64 / self.config.sample_rate
1071 }
1072}
1073
1074/// Compliance result.
1075#[derive(Clone, Debug)]
1076pub struct ComplianceResult {
1077 /// Standard being checked.
1078 pub standard: Standard,
1079 /// Is loudness compliant?
1080 pub loudness_compliant: bool,
1081 /// Is peak compliant?
1082 pub peak_compliant: bool,
1083 /// Is LRA acceptable?
1084 pub lra_acceptable: bool,
1085 /// Measured integrated loudness.
1086 pub integrated_lufs: f64,
1087 /// Measured true peak.
1088 pub true_peak_dbtp: f64,
1089 /// Measured loudness range.
1090 pub loudness_range: f64,
1091 /// Target loudness.
1092 pub target_lufs: f64,
1093 /// Maximum allowed peak.
1094 pub max_peak_dbtp: f64,
1095 /// Deviation from target in LU.
1096 pub deviation_lu: f64,
1097}
1098
1099impl ComplianceResult {
1100 /// Check if fully compliant (loudness and peak).
1101 pub fn is_compliant(&self) -> bool {
1102 self.loudness_compliant && self.peak_compliant
1103 }
1104
1105 /// Get the standard name.
1106 pub fn standard_name(&self) -> &str {
1107 self.standard.name()
1108 }
1109
1110 /// Get recommended gain adjustment to meet target.
1111 ///
1112 /// Returns gain in dB (positive = increase, negative = decrease).
1113 pub fn recommended_gain_db(&self) -> f64 {
1114 if self.integrated_lufs.is_finite() {
1115 self.target_lufs - self.integrated_lufs
1116 } else {
1117 0.0
1118 }
1119 }
1120}
1121
1122#[cfg(test)]
1123mod tests {
1124 use super::*;
1125
1126 /// EBU R128 reference signal test: 997 Hz sine at -23 LUFS.
1127 ///
1128 /// The K-weighting pre-filter (Stage 1: high-shelf at 1681 Hz, G≈4 dB) adds
1129 /// approximately +3.41 dB power gain at 997 Hz, so the raw signal amplitude
1130 /// must be compensated by the inverse of that gain.
1131 ///
1132 /// Calibration procedure:
1133 /// 1. Target LUFS = -23; after filter the mean-square must equal
1134 /// `10^((-23 + 0.691) / 10)`.
1135 /// 2. The K-weight filter at 997 Hz has power gain ≈ 2.193 (+3.41 dB).
1136 /// 3. Required pre-filter RMS² = target_power / filter_gain.
1137 /// 4. For a sine wave: peak amplitude A = sqrt(2 × RMS²).
1138 ///
1139 /// We generate 10 seconds of stereo audio (enough for gating to converge)
1140 /// and assert the integrated loudness is within ±0.5 LUFS of -23.0.
1141 #[test]
1142 fn test_ebu_r128_reference_signal() {
1143 let sample_rate = 48000.0_f64;
1144 let channels = 2_usize;
1145 let duration_secs = 10.0_f64;
1146 let freq_hz = 997.0_f64;
1147
1148 // K-weighting pre-filter adds ~3.41 dB power gain at 997 Hz for the
1149 // standard ITU-R BS.1770-4 coefficients implemented in filters.rs.
1150 // amplitude calibrated so the integrated loudness converges to -23 LUFS.
1151 let k_weight_power_gain_db = 3.41_f64;
1152 let target_power = 10.0_f64.powf((-23.0_f64 + 0.691) / 10.0);
1153 let filter_power_gain = 10.0_f64.powf(k_weight_power_gain_db / 10.0);
1154 // For a stereo signal with identical L/R: the gating normalises by
1155 // total_weight = 2.0, so block_ms = (ch0_ms + ch1_ms) / N / 2 = A²/2 * gain.
1156 // Solving A²/2 * gain = target_power:
1157 let amplitude = (2.0 * target_power / filter_power_gain).sqrt();
1158
1159 let total_samples = (sample_rate * duration_secs) as usize;
1160 let mut interleaved = Vec::with_capacity(total_samples * channels);
1161
1162 for i in 0..total_samples {
1163 let t = i as f64 / sample_rate;
1164 let sample = amplitude * (2.0 * std::f64::consts::PI * freq_hz * t).sin();
1165 // Stereo: identical L and R channels
1166 interleaved.push(sample);
1167 interleaved.push(sample);
1168 }
1169
1170 let config = MeterConfig::new(Standard::EbuR128, sample_rate, channels);
1171 let mut meter = LoudnessMeter::new(config).expect("Failed to create LoudnessMeter");
1172 meter.process_f64(&interleaved);
1173
1174 let metrics = meter.metrics();
1175 let integrated = metrics.integrated_lufs;
1176
1177 assert!(
1178 integrated.is_finite(),
1179 "Integrated loudness should be finite, got {integrated}"
1180 );
1181 assert!(
1182 (integrated - (-23.0)).abs() <= 0.5,
1183 "Expected -23.0 LUFS ±0.5, got {integrated:.2} LUFS"
1184 );
1185 }
1186
1187 /// Verify TidalHiFi standard has correct target loudness.
1188 #[test]
1189 fn test_tidal_hifi_standard() {
1190 let s = Standard::TidalHiFi;
1191 assert_eq!(s.target_lufs(), -14.0);
1192 assert_eq!(s.max_true_peak_dbtp(), -1.0);
1193 assert_eq!(s.name(), "Tidal HiFi");
1194 }
1195
1196 /// Verify AmazonMusicHd standard has correct target loudness.
1197 #[test]
1198 fn test_amazon_music_hd_standard() {
1199 let s = Standard::AmazonMusicHd;
1200 assert_eq!(s.target_lufs(), -14.0);
1201 assert_eq!(s.max_true_peak_dbtp(), -1.0);
1202 assert_eq!(s.name(), "Amazon Music HD");
1203 }
1204}