Skip to main content

audio_engine_core/processor/loudness/
meter.rs

1//! EBU R128 loudness meter and 4x FIR true peak detector.
2
3use crate::processor::dsp::linear_to_db;
4use std::sync::OnceLock;
5
6const TRUE_PEAK_PHASES: usize = 4;
7const TRUE_PEAK_FIR_TAPS: usize = 49;
8const TRUE_PEAK_DELAY: usize = TRUE_PEAK_FIR_TAPS.div_ceil(TRUE_PEAK_PHASES);
9const TRUE_PEAK_HISTORY_LEN: usize = TRUE_PEAK_DELAY * 2;
10const TRUE_PEAK_INTER_SAMPLE_TAPS: usize = TRUE_PEAK_DELAY - 1;
11
12static TRUE_PEAK_FIR: OnceLock<TruePeakFir> = OnceLock::new();
13
14#[derive(Clone, Copy)]
15struct TruePeakFir {
16    sample_phase_coeff: f64,
17    inter_sample_coeffs: [[f64; TRUE_PEAK_INTER_SAMPLE_TAPS]; TRUE_PEAK_PHASES - 1],
18}
19
20/// EBU R128 loudness meter using the ebur128 crate
21/// Measures integrated, short-term, momentary loudness and loudness range
22pub struct LoudnessMeter {
23    ebur128: Option<ebur128::EbuR128>,
24    sample_rate: u32,
25    channels: usize,
26    // Cached results
27    integrated_loudness: f64,
28    short_term_loudness: f64,
29    momentary_loudness: f64,
30    loudness_range: f64,
31    true_peak: f64,
32    samples_processed: u64,
33    // 4x FIR true peak detector (per channel).
34    true_peak_detectors: Vec<TruePeakDetector>,
35}
36
37impl LoudnessMeter {
38    pub fn new(channels: usize, sample_rate: u32) -> Self {
39        let ebur128 =
40            ebur128::EbuR128::new(channels as u32, sample_rate, ebur128::Mode::all()).ok();
41
42        // Create true peak detector for each channel
43        let true_peak_detectors = (0..channels).map(|_| TruePeakDetector::new()).collect();
44
45        Self {
46            ebur128,
47            sample_rate,
48            channels,
49            integrated_loudness: -70.0,
50            short_term_loudness: -70.0,
51            momentary_loudness: -70.0,
52            loudness_range: 0.0,
53            true_peak: -70.0,
54            samples_processed: 0,
55            true_peak_detectors,
56        }
57    }
58
59    /// Reset meter state (call when starting a new track)
60    pub fn reset(&mut self) {
61        if let Some(ref mut ebur) = self.ebur128 {
62            ebur.reset();
63        }
64        self.integrated_loudness = -70.0;
65        self.short_term_loudness = -70.0;
66        self.momentary_loudness = -70.0;
67        self.loudness_range = 0.0;
68        self.true_peak = -70.0;
69        self.samples_processed = 0;
70        // Reset true peak detectors
71        for detector in &mut self.true_peak_detectors {
72            detector.reset();
73        }
74    }
75
76    /// Process interleaved f64 samples
77    pub fn process(&mut self, samples: &[f64]) {
78        let Some(ref mut ebur) = self.ebur128 else {
79            return;
80        };
81
82        let frames = samples.len() / self.channels;
83        if frames == 0 {
84            return;
85        }
86        let sample_count = frames * self.channels;
87        let samples = &samples[..sample_count];
88
89        if let Err(e) = ebur.add_frames_f64(samples) {
90            log::warn!("EBU R128 add_frames error: {:?}", e);
91            return;
92        }
93
94        self.samples_processed += frames as u64;
95
96        // Update measurements
97        if let Ok(loudness) = ebur.loudness_global() {
98            self.integrated_loudness = loudness;
99        }
100
101        if let Ok(loudness) = ebur.loudness_shortterm() {
102            self.short_term_loudness = loudness;
103        }
104
105        if let Ok(loudness) = ebur.loudness_momentary() {
106            self.momentary_loudness = loudness;
107        }
108
109        if let Ok(lra) = ebur.loudness_range() {
110            self.loudness_range = lra;
111        }
112
113        // True peak using 4x polyphase FIR oversampling.
114        let fir = true_peak_fir();
115        for frame in samples.chunks_exact(self.channels) {
116            for (sample, detector) in frame.iter().zip(self.true_peak_detectors.iter_mut()) {
117                detector.process_sample(*sample, fir);
118            }
119        }
120
121        // Get maximum true peak across all channels
122        let max_true_peak = self
123            .true_peak_detectors
124            .iter()
125            .map(|d| d.max_true_peak())
126            .fold(0.0_f64, f64::max);
127
128        if max_true_peak > 0.0 {
129            let peak_db = 20.0 * max_true_peak.log10();
130            self.true_peak = peak_db.max(self.true_peak);
131        }
132    }
133
134    pub fn integrated_loudness(&self) -> f64 {
135        self.integrated_loudness
136    }
137    pub fn short_term_loudness(&self) -> f64 {
138        self.short_term_loudness
139    }
140    pub fn momentary_loudness(&self) -> f64 {
141        self.momentary_loudness
142    }
143    pub fn loudness_range(&self) -> f64 {
144        self.loudness_range
145    }
146    pub fn true_peak(&self) -> f64 {
147        self.true_peak
148    }
149    pub fn samples_processed(&self) -> u64 {
150        self.samples_processed
151    }
152
153    pub fn has_reliable_measurement(&self) -> bool {
154        let min_samples = (self.sample_rate as f64 * 0.4) as u64;
155        self.samples_processed >= min_samples
156    }
157}
158
159/// True peak detector using 4x polyphase FIR oversampling.
160///
161/// The FIR follows libebur128's 49-tap Hanning-windowed sinc polyphase
162/// interpolator shape. It replaces the older cubic interpolation estimate with
163/// a bounded, no-heap process path. Formal BS.1770 conformance still depends on
164/// validating against reference corpus data.
165///
166/// This is used for measurement, not limiting. The limiter above
167/// handles peak limiting without oversampling (acceptable for most use cases).
168pub struct TruePeakDetector {
169    /// Causal FIR history duplicated once so dot products read contiguous slices.
170    history: [f64; TRUE_PEAK_HISTORY_LEN],
171    write_pos: usize,
172    /// Maximum true peak detected
173    max_true_peak: f64,
174}
175
176impl TruePeakDetector {
177    pub fn new() -> Self {
178        let _ = true_peak_fir();
179        Self {
180            history: [0.0; TRUE_PEAK_HISTORY_LEN],
181            write_pos: 0,
182            max_true_peak: 0.0,
183        }
184    }
185
186    /// Process samples and update true peak measurement
187    pub fn process(&mut self, samples: &[f64]) {
188        let fir = true_peak_fir();
189        for &sample in samples {
190            self.process_sample(sample, fir);
191        }
192    }
193
194    /// Process one channel from an interleaved buffer without allocating.
195    pub fn process_strided(&mut self, samples: &[f64], offset: usize, stride: usize) {
196        let fir = true_peak_fir();
197        let mut index = offset;
198        while index < samples.len() {
199            self.process_sample(samples[index], fir);
200            index += stride;
201        }
202    }
203
204    #[inline]
205    fn process_sample(&mut self, sample: f64, fir: &TruePeakFir) {
206        self.max_true_peak = self.max_true_peak.max(sample.abs());
207
208        self.history[self.write_pos] = sample;
209        self.history[self.write_pos + TRUE_PEAK_DELAY] = sample;
210
211        let dot_base = self.write_pos + TRUE_PEAK_DELAY - 11;
212        let history = &self.history[dot_base..dot_base + TRUE_PEAK_INTER_SAMPLE_TAPS];
213        let phase1 = dot12_contiguous(history, &fir.inter_sample_coeffs[0]);
214        let phase2 = dot12_contiguous(history, &fir.inter_sample_coeffs[1]);
215        let phase3 = dot12_contiguous(history, &fir.inter_sample_coeffs[2]);
216
217        self.max_true_peak = self
218            .max_true_peak
219            .max(phase1.abs())
220            .max(phase2.abs())
221            .max(phase3.abs());
222
223        self.write_pos += 1;
224        if self.write_pos == TRUE_PEAK_DELAY {
225            self.write_pos = 0;
226        }
227    }
228
229    /// Get maximum true peak detected (linear)
230    pub fn max_true_peak(&self) -> f64 {
231        self.max_true_peak
232    }
233
234    /// Get maximum true peak in dBTP
235    pub fn max_true_peak_db(&self) -> f64 {
236        linear_to_db(self.max_true_peak)
237    }
238
239    /// Reset detector state
240    pub fn reset(&mut self) {
241        self.history.fill(0.0);
242        self.write_pos = 0;
243        self.max_true_peak = 0.0;
244    }
245}
246
247impl Default for TruePeakDetector {
248    fn default() -> Self {
249        Self::new()
250    }
251}
252
253fn true_peak_fir() -> &'static TruePeakFir {
254    TRUE_PEAK_FIR.get_or_init(generate_true_peak_fir)
255}
256
257fn generate_true_peak_fir() -> TruePeakFir {
258    let mut fir = TruePeakFir {
259        sample_phase_coeff: 0.0,
260        inter_sample_coeffs: [[0.0; TRUE_PEAK_INTER_SAMPLE_TAPS]; TRUE_PEAK_PHASES - 1],
261    };
262    let center = (TRUE_PEAK_FIR_TAPS as f64 - 1.0) * 0.5;
263
264    for tap_index in 0..TRUE_PEAK_FIR_TAPS {
265        let phase = tap_index % TRUE_PEAK_PHASES;
266        let position = tap_index as f64 - center;
267        let window = 0.5
268            * (1.0
269                - (2.0 * std::f64::consts::PI * tap_index as f64
270                    / (TRUE_PEAK_FIR_TAPS as f64 - 1.0))
271                    .cos());
272        let coeff = sinc(position / TRUE_PEAK_PHASES as f64) * window;
273
274        if coeff.abs() > 1.0e-12 {
275            if phase == 0 {
276                fir.sample_phase_coeff = coeff;
277            } else {
278                fir.inter_sample_coeffs[phase - 1][tap_index / TRUE_PEAK_PHASES] = coeff;
279            }
280        }
281    }
282
283    fir
284}
285
286#[inline]
287fn dot12_contiguous(history: &[f64], coeffs: &[f64; TRUE_PEAK_INTER_SAMPLE_TAPS]) -> f64 {
288    history[11] * coeffs[0]
289        + history[10] * coeffs[1]
290        + history[9] * coeffs[2]
291        + history[8] * coeffs[3]
292        + history[7] * coeffs[4]
293        + history[6] * coeffs[5]
294        + history[5] * coeffs[6]
295        + history[4] * coeffs[7]
296        + history[3] * coeffs[8]
297        + history[2] * coeffs[9]
298        + history[1] * coeffs[10]
299        + history[0] * coeffs[11]
300}
301
302#[inline]
303fn sinc(x: f64) -> f64 {
304    if x.abs() < 1.0e-12 {
305        1.0
306    } else {
307        let pix = std::f64::consts::PI * x;
308        pix.sin() / pix
309    }
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315
316    fn deterministic_interleaved(frames: usize, channels: usize) -> Vec<f64> {
317        let mut samples = Vec::with_capacity(frames * channels);
318        for frame in 0..frames {
319            for ch in 0..channels {
320                let sample = ((frame as f64 * 0.017) + ch as f64 * 0.13).sin() * 0.5;
321                samples.push(sample);
322            }
323        }
324        samples
325    }
326
327    #[test]
328    fn true_peak_strided_matches_channel_extract_for_common_channel_counts() {
329        for channels in [1, 2, 6, 8] {
330            let samples = deterministic_interleaved(512, channels);
331
332            for ch in 0..channels {
333                let channel_samples: Vec<f64> =
334                    samples.iter().skip(ch).step_by(channels).copied().collect();
335                let mut contiguous = TruePeakDetector::new();
336                let mut strided = TruePeakDetector::new();
337
338                contiguous.process(&channel_samples);
339                strided.process_strided(&samples, ch, channels);
340
341                assert_eq!(
342                    contiguous.max_true_peak().to_bits(),
343                    strided.max_true_peak().to_bits(),
344                    "channels={channels}, channel={ch}"
345                );
346            }
347        }
348    }
349
350    #[test]
351    fn loudness_meter_truncates_partial_frames() {
352        let mut meter = LoudnessMeter::new(2, 48_000);
353        let samples = vec![0.1, -0.1, 0.2];
354
355        meter.process(&samples);
356
357        assert_eq!(meter.samples_processed(), 1);
358    }
359
360    #[test]
361    fn loudness_meter_process_is_steady_state_no_alloc() {
362        let mut meter = LoudnessMeter::new(2, 48_000);
363        let samples = deterministic_interleaved(64, 2);
364
365        assert_no_alloc::assert_no_alloc(|| {
366            for _ in 0..1_000 {
367                meter.process(&samples);
368            }
369        });
370    }
371
372    #[test]
373    fn loudness_meter_handles_surround_channel_counts() {
374        for channels in [1, 2, 6, 8] {
375            let mut meter = LoudnessMeter::new(channels, 48_000);
376            let samples = deterministic_interleaved(256, channels);
377
378            meter.process(&samples);
379
380            assert_eq!(meter.samples_processed(), 256);
381            assert!(meter.true_peak().is_finite());
382        }
383    }
384
385    #[test]
386    fn true_peak_fir_matches_libebur128_polyphase_shape() {
387        let fir = true_peak_fir();
388
389        assert!(fir.sample_phase_coeff.is_finite());
390        assert!(fir.sample_phase_coeff.abs() > 1.0e-12);
391
392        for phase in 0..TRUE_PEAK_PHASES - 1 {
393            for tap in 0..TRUE_PEAK_INTER_SAMPLE_TAPS {
394                assert!(fir.inter_sample_coeffs[phase][tap].is_finite());
395                assert!(fir.inter_sample_coeffs[phase][tap].abs() > 1.0e-12);
396            }
397        }
398    }
399
400    #[test]
401    fn true_peak_reset_clears_ring_history() {
402        let mut detector = TruePeakDetector::new();
403        detector.process(&[1.0; TRUE_PEAK_DELAY]);
404        assert!(detector.max_true_peak() > 0.0);
405
406        detector.reset();
407        detector.process(&[0.0; TRUE_PEAK_DELAY]);
408
409        assert_eq!(detector.max_true_peak(), 0.0);
410    }
411
412    #[test]
413    fn true_peak_cross_buffer_continuity_matches_single_process() {
414        let samples: Vec<f64> = (0..1024).map(|i| (i as f64 * 0.071).sin()).collect();
415        let mut single = TruePeakDetector::new();
416        let mut chunked = TruePeakDetector::new();
417
418        single.process(&samples);
419        for chunk in samples.chunks(17) {
420            chunked.process(chunk);
421        }
422
423        assert_eq!(
424            single.max_true_peak().to_bits(),
425            chunked.max_true_peak().to_bits()
426        );
427    }
428
429    #[test]
430    fn true_peak_impulse_reaches_sample_peak_without_cubic_overshoot() {
431        let mut detector = TruePeakDetector::new();
432        let mut samples = vec![0.0; TRUE_PEAK_DELAY * 2];
433        samples[TRUE_PEAK_DELAY / 2] = 1.0;
434
435        detector.process(&samples);
436
437        assert!(detector.max_true_peak() >= 1.0);
438        assert!(detector.max_true_peak() < 1.1);
439    }
440}