Skip to main content

math_audio_dsp/
ebur128.rs

1//! Pure Rust implementation of ITU-R BS.1770-4 / EBU R128 loudness measurement.
2//!
3//! Implements K-weighting, momentary/short-term/integrated loudness,
4//! sample peak, and true peak (4x oversampling).
5
6use std::collections::VecDeque;
7use std::f64::consts::PI;
8
9// In tests we use a much smaller cap so overflow behaviour can be exercised
10// quickly without allocating gigabytes of audio.
11#[cfg(test)]
12const MAX_GATING_BLOCKS: usize = 100;
13#[cfg(not(test))]
14const MAX_GATING_BLOCKS: usize = 36_000;
15
16// ── Mode bitflags ───────────────────────────────────────────────────────────
17
18/// Measurement modes (bitflags).
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub struct Mode(u32);
21
22impl Mode {
23    pub const M: Mode = Mode(1 << 0);
24    pub const S: Mode = Mode(1 << 1);
25    pub const I: Mode = Mode(1 << 2);
26    pub const SAMPLE_PEAK: Mode = Mode(1 << 3);
27    pub const TRUE_PEAK: Mode = Mode(1 << 4);
28
29    pub const fn all() -> Mode {
30        Mode(0x1F)
31    }
32
33    fn has(self, flag: Mode) -> bool {
34        self.0 & flag.0 != 0
35    }
36}
37
38impl std::ops::BitOr for Mode {
39    type Output = Mode;
40    fn bitor(self, rhs: Mode) -> Mode {
41        Mode(self.0 | rhs.0)
42    }
43}
44
45// ── K-weighting filter ─────────────────────────────────────────────────────
46
47/// Second-order IIR section (biquad) in Direct Form II Transposed.
48#[derive(Clone)]
49struct Biquad {
50    b0: f64,
51    b1: f64,
52    b2: f64,
53    a1: f64,
54    a2: f64,
55    z1: f64,
56    z2: f64,
57}
58
59impl Biquad {
60    fn process(&mut self, x: f64) -> f64 {
61        let y = self.b0 * x + self.z1;
62        self.z1 = self.b1 * x - self.a1 * y + self.z2;
63        self.z2 = self.b2 * x - self.a2 * y;
64        y
65    }
66
67    fn reset(&mut self) {
68        self.z1 = 0.0;
69        self.z2 = 0.0;
70    }
71}
72
73/// K-weighting: pre-filter (high shelf) + RLB high-pass.
74/// Coefficients from ITU-R BS.1770-4, computed via bilinear transform.
75#[derive(Clone)]
76struct KWeightFilter {
77    stage1: Biquad,
78    stage2: Biquad,
79}
80
81impl KWeightFilter {
82    fn new(sample_rate: u32) -> Self {
83        let (s1, s2) = if sample_rate == 48000 {
84            Self::coeffs_48k()
85        } else {
86            Self::compute_coeffs(sample_rate as f64)
87        };
88        Self {
89            stage1: s1,
90            stage2: s2,
91        }
92    }
93
94    /// Hardcoded coefficients for 48 kHz (most common case).
95    fn coeffs_48k() -> (Biquad, Biquad) {
96        // Stage 1: Pre-filter (high shelf)
97        let s1 = Biquad {
98            b0: 1.53512485958697,
99            b1: -2.69169618940638,
100            b2: 1.19839281085285,
101            a1: -1.69065929318241,
102            a2: 0.73248077421585,
103            z1: 0.0,
104            z2: 0.0,
105        };
106        // Stage 2: RLB high-pass
107        let s2 = Biquad {
108            b0: 1.0,
109            b1: -2.0,
110            b2: 1.0,
111            a1: -1.99004745483398,
112            a2: 0.99007225036621,
113            z1: 0.0,
114            z2: 0.0,
115        };
116        (s1, s2)
117    }
118
119    /// Compute coefficients for arbitrary sample rate via bilinear transform.
120    fn compute_coeffs(fs: f64) -> (Biquad, Biquad) {
121        // Stage 1: High shelf from BS.1770-4 analog prototype
122        // Analog: H(s) = Vh * (s^2 + (sqrt(Vh)/Q)*s + 1) / (s^2 + (1/(Q*sqrt(Vh)))*s + 1)
123        // with fc=1681.974450955533, Q=0.7071752369554196, dB_gain=3.999843853973347
124        let fc1 = 1681.974450955533;
125        let q1 = 0.7071752369554196;
126        let db1 = 3.999843853973347;
127        let vh = 10.0_f64.powf(db1 / 20.0);
128        let vb = vh.powf(0.4996667741545416);
129        let k1 = (PI * fc1 / fs).tan();
130        let k1sq = k1 * k1;
131        let denom1 = 1.0 + k1 / q1 + k1sq;
132        let s1 = Biquad {
133            b0: (vh + vb * k1 / q1 + k1sq) / denom1,
134            b1: 2.0 * (k1sq - vh) / denom1,
135            b2: (vh - vb * k1 / q1 + k1sq) / denom1,
136            a1: 2.0 * (k1sq - 1.0) / denom1,
137            a2: (1.0 - k1 / q1 + k1sq) / denom1,
138            z1: 0.0,
139            z2: 0.0,
140        };
141
142        // Stage 2: RLB high-pass (2nd order Butterworth high-pass at 38.13547087602444 Hz)
143        let fc2 = 38.13547087602444;
144        let q2 = 0.5003270373238773;
145        let k2 = (PI * fc2 / fs).tan();
146        let k2sq = k2 * k2;
147        let denom2 = 1.0 + k2 / q2 + k2sq;
148        let s2 = Biquad {
149            b0: 1.0 / denom2,
150            b1: -2.0 / denom2,
151            b2: 1.0 / denom2,
152            a1: 2.0 * (k2sq - 1.0) / denom2,
153            a2: (1.0 - k2 / q2 + k2sq) / denom2,
154            z1: 0.0,
155            z2: 0.0,
156        };
157
158        (s1, s2)
159    }
160
161    fn process(&mut self, x: f64) -> f64 {
162        let y1 = self.stage1.process(x);
163        self.stage2.process(y1)
164    }
165
166    fn reset(&mut self) {
167        self.stage1.reset();
168        self.stage2.reset();
169    }
170}
171
172// ── True peak oversampling ──────────────────────────────────────────────────
173
174/// 4x oversampling FIR for true peak detection.
175/// 48-tap polyphase filter (4 phases × 12 taps) from BS.1770-4 Table 2.
176const TRUE_PEAK_FIR_PHASES: [[f64; 12]; 4] = [
177    [
178        0.0017089843750,
179        -0.0291748046875,
180        -0.0189208984375,
181        0.0776367187500,
182        0.0983886718750,
183        -0.1897583007813,
184        -0.3953857421875,
185        0.8893127441406,
186        0.6444091796875,
187        -0.0517578125000,
188        -0.0245361328125,
189        0.0015869140625,
190    ],
191    [
192        -0.0291748046875,
193        0.0017089843750,
194        0.0776367187500,
195        -0.0189208984375,
196        -0.1897583007813,
197        0.0983886718750,
198        0.8893127441406,
199        -0.3953857421875,
200        -0.0517578125000,
201        0.6444091796875,
202        0.0015869140625,
203        -0.0245361328125,
204    ],
205    [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0],
206    [
207        -0.0245361328125,
208        0.0015869140625,
209        0.6444091796875,
210        -0.0517578125000,
211        -0.3953857421875,
212        0.8893127441406,
213        0.0983886718750,
214        -0.1897583007813,
215        -0.0189208984375,
216        0.0776367187500,
217        0.0017089843750,
218        -0.0291748046875,
219    ],
220];
221
222const TRUE_PEAK_FIR_LEN: usize = 12;
223
224struct TruePeakDetector {
225    history: Vec<[f64; TRUE_PEAK_FIR_LEN]>,
226    peak: Vec<f64>,
227    prev_peak: Vec<f64>,
228}
229
230impl TruePeakDetector {
231    fn new(channels: usize) -> Self {
232        Self {
233            history: vec![[0.0; TRUE_PEAK_FIR_LEN]; channels],
234            peak: vec![0.0; channels],
235            prev_peak: vec![0.0; channels],
236        }
237    }
238
239    fn process_frame(&mut self, ch: usize, sample: f64) {
240        // Shift history
241        let h = &mut self.history[ch];
242        h.copy_within(1.., 0);
243        h[TRUE_PEAK_FIR_LEN - 1] = sample;
244
245        // Evaluate 4 polyphase outputs
246        for phase in &TRUE_PEAK_FIR_PHASES {
247            let mut sum = 0.0;
248            for (i, &coeff) in phase.iter().enumerate() {
249                sum += coeff * h[i];
250            }
251            let abs_val = sum.abs();
252            if abs_val > self.peak[ch] {
253                self.peak[ch] = abs_val;
254            }
255            if abs_val > self.prev_peak[ch] {
256                self.prev_peak[ch] = abs_val;
257            }
258        }
259    }
260
261    fn reset(&mut self) {
262        for h in &mut self.history {
263            h.fill(0.0);
264        }
265        self.peak.fill(0.0);
266        self.prev_peak.fill(0.0);
267    }
268}
269
270// ── Gating block ring buffer ────────────────────────────────────────────────
271
272/// Ring buffer for sub-block energies (400ms momentary = 4 × 100ms, 3s short-term = 30 × 100ms).
273struct SubBlockRing {
274    buf: Vec<f64>,
275    pos: usize,
276    count: usize,
277}
278
279impl SubBlockRing {
280    fn new(capacity: usize) -> Self {
281        Self {
282            buf: vec![0.0; capacity],
283            pos: 0,
284            count: 0,
285        }
286    }
287
288    fn push(&mut self, energy: f64) {
289        self.buf[self.pos] = energy;
290        self.pos = (self.pos + 1) % self.buf.len();
291        if self.count < self.buf.len() {
292            self.count += 1;
293        }
294    }
295
296    fn mean(&self) -> Option<f64> {
297        if self.count == 0 {
298            return None;
299        }
300        let sum: f64 = if self.count == self.buf.len() {
301            self.buf.iter().sum()
302        } else {
303            self.buf[..self.count].iter().sum()
304        };
305        Some(sum / self.count as f64)
306    }
307
308    fn is_full(&self) -> bool {
309        self.count >= self.buf.len()
310    }
311
312    fn reset(&mut self) {
313        self.buf.fill(0.0);
314        self.pos = 0;
315        self.count = 0;
316    }
317}
318
319// ── Channel weights ─────────────────────────────────────────────────────────
320
321fn channel_weight(ch: usize, num_channels: usize) -> f64 {
322    if num_channels <= 2 {
323        // Mono or stereo: all channels weight 1.0
324        1.0
325    } else if num_channels == 5 {
326        // 5.0: L, R, C, Ls, Rs
327        match ch {
328            0..=2 => 1.0,  // L, R, C
329            3 | 4 => 1.41, // Ls, Rs (surround +1.5 dB)
330            _ => 0.0,
331        }
332    } else if num_channels == 6 {
333        // 5.1: L, R, C, LFE, Ls, Rs
334        match ch {
335            0..=2 => 1.0,  // L, R, C
336            3 => 0.0,      // LFE excluded
337            4 | 5 => 1.41, // Ls, Rs
338            _ => 0.0,
339        }
340    } else {
341        1.0
342    }
343}
344
345// ── EbuR128 ─────────────────────────────────────────────────────────────────
346
347/// EBU R128 / ITU-R BS.1770-4 loudness meter.
348pub struct EbuR128 {
349    channels: u32,
350    #[allow(dead_code)]
351    sample_rate: u32,
352    mode: Mode,
353
354    // K-weighting filters (one per channel)
355    filters: Vec<KWeightFilter>,
356    channel_weights: Vec<f64>,
357
358    // Sub-block accumulation (100ms sub-blocks)
359    sub_block_frames: usize,   // frames per 100ms sub-block
360    sub_block_accum: Vec<f64>, // per-channel energy accumulator
361    sub_block_pos: usize,      // frames accumulated in current sub-block
362
363    // Momentary (400ms = 4 sub-blocks) and short-term (3s = 30 sub-blocks)
364    momentary_ring: SubBlockRing,
365    shortterm_ring: SubBlockRing,
366
367    // Integrated gating: store all block energies for two-pass gating
368    gating_blocks: VecDeque<f64>,
369
370    // Peak tracking
371    sample_peak: Vec<f64>,
372    prev_sample_peak: Vec<f64>,
373    true_peak_detector: Option<TruePeakDetector>,
374}
375
376impl EbuR128 {
377    /// Create a new EBU R128 loudness meter.
378    ///
379    /// # Errors
380    /// Returns an error if channels is 0.
381    pub fn new(channels: u32, sample_rate: u32, mode: Mode) -> Result<Self, String> {
382        if channels == 0 {
383            return Err("channels must be > 0".into());
384        }
385        let nc = channels as usize;
386        let sub_block_frames = (sample_rate as usize) / 10; // 100ms
387
388        let filters: Vec<KWeightFilter> =
389            (0..nc).map(|_| KWeightFilter::new(sample_rate)).collect();
390        let channel_weights: Vec<f64> = (0..nc).map(|ch| channel_weight(ch, nc)).collect();
391
392        let true_peak_detector = if mode.has(Mode::TRUE_PEAK) {
393            Some(TruePeakDetector::new(nc))
394        } else {
395            None
396        };
397
398        Ok(Self {
399            channels,
400            sample_rate,
401            mode,
402            filters,
403            channel_weights,
404            sub_block_frames,
405            sub_block_accum: vec![0.0; nc],
406            sub_block_pos: 0,
407            momentary_ring: SubBlockRing::new(4), // 4 × 100ms = 400ms
408            shortterm_ring: SubBlockRing::new(30), // 30 × 100ms = 3s
409            gating_blocks: if mode.has(Mode::I) {
410                // Pre-allocate for ~10 minutes (6000 blocks at 10 blocks/sec)
411                // to avoid re-allocations on the audio thread hot path.
412                VecDeque::with_capacity(6_000)
413            } else {
414                VecDeque::new()
415            },
416            sample_peak: vec![0.0; nc],
417            prev_sample_peak: vec![0.0; nc],
418            true_peak_detector,
419        })
420    }
421
422    /// Feed interleaved f32 audio frames.
423    pub fn add_frames_f32(&mut self, samples: &[f32]) -> Result<(), String> {
424        let nc = self.channels as usize;
425        if !samples.len().is_multiple_of(nc) {
426            return Err("samples length must be a multiple of channel count".into());
427        }
428
429        for frame in samples.chunks_exact(nc) {
430            for (ch, &s) in frame.iter().enumerate() {
431                let x = s as f64;
432
433                // Sample peak
434                if self.mode.has(Mode::SAMPLE_PEAK) {
435                    let abs_x = x.abs();
436                    if abs_x > self.sample_peak[ch] {
437                        self.sample_peak[ch] = abs_x;
438                    }
439                    if abs_x > self.prev_sample_peak[ch] {
440                        self.prev_sample_peak[ch] = abs_x;
441                    }
442                }
443
444                // True peak
445                if let Some(ref mut tp) = self.true_peak_detector {
446                    tp.process_frame(ch, x);
447                }
448
449                // K-weighting
450                let y = self.filters[ch].process(x);
451                self.sub_block_accum[ch] += y * y;
452            }
453
454            self.sub_block_pos += 1;
455
456            // Complete a 100ms sub-block
457            if self.sub_block_pos >= self.sub_block_frames {
458                self.complete_sub_block();
459            }
460        }
461
462        Ok(())
463    }
464
465    fn complete_sub_block(&mut self) {
466        let nc = self.channels as usize;
467        let n = self.sub_block_frames as f64;
468
469        // Weighted energy for this sub-block
470        let mut block_energy = 0.0;
471        for ch in 0..nc {
472            block_energy += self.channel_weights[ch] * (self.sub_block_accum[ch] / n);
473        }
474
475        // Push to ring buffers
476        self.momentary_ring.push(block_energy);
477        self.shortterm_ring.push(block_energy);
478
479        // For integrated loudness: store block energy when we have a full momentary window
480        if self.mode.has(Mode::I)
481            && self.momentary_ring.is_full()
482            && let Some(mean_energy) = self.momentary_ring.mean()
483        {
484            // Cap at ~1 hour of blocks (36000 at 10 blocks/sec) to prevent
485            // unbounded memory growth during long playback sessions.
486            // When full, drop the oldest block (approximation acceptable for
487            // integrated loudness which is already a long-term average).
488            if self.gating_blocks.len() >= MAX_GATING_BLOCKS {
489                self.gating_blocks.pop_front();
490            }
491            self.gating_blocks.push_back(mean_energy);
492        }
493
494        // Reset accumulators
495        self.sub_block_accum.fill(0.0);
496        self.sub_block_pos = 0;
497    }
498
499    /// Momentary loudness (400ms window) in LUFS.
500    pub fn loudness_momentary(&self) -> Result<f64, String> {
501        match self.momentary_ring.mean() {
502            Some(e) if e > 0.0 => Ok(energy_to_loudness(e)),
503            Some(_) => Ok(f64::NEG_INFINITY),
504            None => Ok(f64::NEG_INFINITY),
505        }
506    }
507
508    /// Short-term loudness (3s window) in LUFS.
509    pub fn loudness_shortterm(&self) -> Result<f64, String> {
510        match self.shortterm_ring.mean() {
511            Some(e) if e > 0.0 => Ok(energy_to_loudness(e)),
512            Some(_) => Ok(f64::NEG_INFINITY),
513            None => Ok(f64::NEG_INFINITY),
514        }
515    }
516
517    /// Integrated loudness (entire program) in LUFS, with EBU R128 two-pass gating.
518    pub fn loudness_global(&self) -> Result<f64, String> {
519        if self.gating_blocks.is_empty() {
520            return Ok(f64::NEG_INFINITY);
521        }
522        Ok(self.compute_gated_loudness())
523    }
524
525    /// Two-pass gating algorithm per EBU R128.
526    /// Zero-allocation: computes means via running sum+count instead of collecting into Vecs.
527    fn compute_gated_loudness(&self) -> f64 {
528        let blocks = &self.gating_blocks;
529        if blocks.is_empty() {
530            return f64::NEG_INFINITY;
531        }
532
533        // Pass 1: Absolute gate at -70 LUFS
534        let abs_gate_energy = loudness_to_energy(-70.0);
535        let mut sum_abs = 0.0f64;
536        let mut count_abs = 0usize;
537        for &e in blocks {
538            if e > abs_gate_energy {
539                sum_abs += e;
540                count_abs += 1;
541            }
542        }
543        if count_abs == 0 {
544            return f64::NEG_INFINITY;
545        }
546
547        let mean_above_abs = sum_abs / count_abs as f64;
548
549        // Pass 2: Relative gate at mean - 10 LUFS
550        let rel_gate_energy = mean_above_abs * loudness_to_energy(-10.0); // -10 dB below mean
551        let mut sum_rel = 0.0f64;
552        let mut count_rel = 0usize;
553        for &e in blocks {
554            if e > rel_gate_energy {
555                sum_rel += e;
556                count_rel += 1;
557            }
558        }
559        if count_rel == 0 {
560            return f64::NEG_INFINITY;
561        }
562
563        let mean_above_rel = sum_rel / count_rel as f64;
564        energy_to_loudness(mean_above_rel)
565    }
566
567    /// Sample peak for a given channel (maximum absolute sample value seen).
568    pub fn sample_peak(&self, channel: u32) -> Result<f64, String> {
569        let ch = channel as usize;
570        if ch >= self.channels as usize {
571            return Err(format!("channel {} out of range", channel));
572        }
573        Ok(self.sample_peak[ch])
574    }
575
576    /// Previous sample peak for a given channel (since last snapshot).
577    /// Resets the stored value after reading (snapshot-and-reset semantics).
578    pub fn prev_sample_peak(&mut self, channel: u32) -> Result<f64, String> {
579        let ch = channel as usize;
580        if ch >= self.channels as usize {
581            return Err(format!("channel {} out of range", channel));
582        }
583        let val = self.prev_sample_peak[ch];
584        self.prev_sample_peak[ch] = 0.0;
585        Ok(val)
586    }
587
588    /// Previous true peak for a given channel (since last snapshot).
589    /// Resets the stored value after reading (snapshot-and-reset semantics).
590    pub fn prev_true_peak(&mut self, channel: u32) -> Result<f64, String> {
591        let ch = channel as usize;
592        if ch >= self.channels as usize {
593            return Err(format!("channel {} out of range", channel));
594        }
595        match &mut self.true_peak_detector {
596            Some(tp) => {
597                let val = tp.prev_peak[ch];
598                tp.prev_peak[ch] = 0.0;
599                Ok(val)
600            }
601            None => Ok(0.0),
602        }
603    }
604
605    /// Returns `(gating_block_count, total_weighted_energy)` for album gain computation.
606    /// Returns `None` if no gating blocks have been accumulated.
607    pub fn gating_block_count_and_energy(&self) -> Option<(u64, f64)> {
608        if self.gating_blocks.is_empty() {
609            return None;
610        }
611
612        // Use blocks above absolute gate for consistency with loudness_global
613        let abs_gate_energy = loudness_to_energy(-70.0);
614        let above_abs: Vec<f64> = self
615            .gating_blocks
616            .iter()
617            .copied()
618            .filter(|&e| e > abs_gate_energy)
619            .collect();
620        if above_abs.is_empty() {
621            return None;
622        }
623        let mean_above_abs = above_abs.iter().sum::<f64>() / above_abs.len() as f64;
624
625        // Relative gate
626        let rel_gate_energy = mean_above_abs * loudness_to_energy(-10.0);
627        let mut count: u64 = 0;
628        let mut total_energy: f64 = 0.0;
629        for &e in &self.gating_blocks {
630            if e > rel_gate_energy {
631                count += 1;
632                total_energy += e;
633            }
634        }
635
636        if count == 0 {
637            None
638        } else {
639            Some((count, total_energy))
640        }
641    }
642
643    /// Reset all state (filters, accumulators, peaks, gating blocks).
644    pub fn reset(&mut self) {
645        for f in &mut self.filters {
646            f.reset();
647        }
648        self.sub_block_accum.fill(0.0);
649        self.sub_block_pos = 0;
650        self.momentary_ring.reset();
651        self.shortterm_ring.reset();
652        self.gating_blocks.clear();
653        self.sample_peak.fill(0.0);
654        self.prev_sample_peak.fill(0.0);
655        if let Some(ref mut tp) = self.true_peak_detector {
656            tp.reset();
657        }
658    }
659}
660
661/// Convert energy to loudness in LUFS: -0.691 + 10 × log10(energy).
662pub fn energy_to_loudness(energy: f64) -> f64 {
663    -0.691 + 10.0 * energy.log10()
664}
665
666/// Convert loudness in LUFS to energy.
667fn loudness_to_energy(lufs: f64) -> f64 {
668    10.0_f64.powf((lufs + 0.691) / 10.0)
669}
670
671#[cfg(test)]
672mod tests {
673    use super::*;
674
675    #[test]
676    fn silence_returns_neg_inf() {
677        let mut meter = EbuR128::new(2, 48000, Mode::all()).unwrap();
678        let silence = vec![0.0f32; 48000 * 2]; // 1 second stereo
679        meter.add_frames_f32(&silence).unwrap();
680        let lufs = meter.loudness_global().unwrap();
681        assert!(lufs == f64::NEG_INFINITY || lufs < -100.0);
682    }
683
684    #[test]
685    fn sine_1khz_loudness() {
686        let sr = 48000;
687        let duration_s = 5;
688        let num_frames = sr * duration_s;
689        let mut samples = vec![0.0f32; num_frames * 2];
690
691        // 0 dBFS 1 kHz sine, both channels
692        for i in 0..num_frames {
693            let t = i as f64 / sr as f64;
694            let s = (2.0 * PI * 1000.0 * t).sin() as f32;
695            samples[i * 2] = s;
696            samples[i * 2 + 1] = s;
697        }
698
699        let mut meter = EbuR128::new(2, sr as u32, Mode::all()).unwrap();
700        meter.add_frames_f32(&samples).unwrap();
701
702        let lufs = meter.loudness_global().unwrap();
703        // Stereo 0 dBFS 1 kHz sine through K-weighting: ~-0.3 LUFS
704        // (K pre-filter adds ~+0.2 dB at 1 kHz, 2 channels × 0.5 RMS² ≈ 1.0)
705        assert!(
706            lufs > -2.0 && lufs < 1.0,
707            "Expected ~-0.3 LUFS for 0dBFS stereo 1kHz sine, got {lufs}"
708        );
709    }
710
711    #[test]
712    fn sample_peak_tracking() {
713        let mut meter = EbuR128::new(1, 48000, Mode::SAMPLE_PEAK).unwrap();
714        let mut samples = vec![0.0f32; 4800]; // 100ms mono
715        samples[100] = 0.75;
716        samples[200] = -0.9;
717        meter.add_frames_f32(&samples).unwrap();
718
719        let peak = meter.sample_peak(0).unwrap();
720        assert!((peak - 0.9).abs() < 1e-6, "Expected peak ~0.9, got {peak}");
721    }
722
723    #[test]
724    fn reset_clears_state() {
725        let mut meter = EbuR128::new(2, 48000, Mode::all()).unwrap();
726        let samples = vec![0.5f32; 48000 * 2];
727        meter.add_frames_f32(&samples).unwrap();
728
729        meter.reset();
730
731        let lufs = meter.loudness_global().unwrap();
732        assert!(lufs == f64::NEG_INFINITY || lufs < -100.0);
733        assert_eq!(meter.sample_peak(0).unwrap(), 0.0);
734    }
735
736    #[test]
737    fn energy_to_loudness_roundtrip() {
738        let lufs = -23.0;
739        let energy = loudness_to_energy(lufs);
740        let back = energy_to_loudness(energy);
741        assert!((back - lufs).abs() < 1e-10);
742    }
743
744    #[test]
745    fn gating_block_count_and_energy() {
746        let sr = 48000;
747        let duration_s = 5;
748        let num_frames = sr * duration_s;
749        let mut samples = vec![0.0f32; num_frames * 2];
750
751        for i in 0..num_frames {
752            let t = i as f64 / sr as f64;
753            let s = (2.0 * PI * 440.0 * t).sin() as f32 * 0.5;
754            samples[i * 2] = s;
755            samples[i * 2 + 1] = s;
756        }
757
758        let mut meter = EbuR128::new(2, sr as u32, Mode::I).unwrap();
759        meter.add_frames_f32(&samples).unwrap();
760
761        let result = meter.gating_block_count_and_energy();
762        assert!(result.is_some());
763        let (count, energy) = result.unwrap();
764        assert!(count > 0);
765        assert!(energy > 0.0);
766
767        // Verify: energy/count should give similar loudness to global
768        let album_lufs = energy_to_loudness(energy / count as f64);
769        let global_lufs = meter.loudness_global().unwrap();
770        assert!(
771            (album_lufs - global_lufs).abs() < 0.5,
772            "Album LUFS {album_lufs} should match global {global_lufs}"
773        );
774    }
775
776    #[test]
777    fn gating_blocks_overflow_oldest_dropped() {
778        // Issue #1: when gating_blocks exceeds MAX_GATING_BLOCKS the oldest
779        // block must be dropped.  With the old Vec::remove(0) this was O(n);
780        // with VecDeque::pop_front() it is O(1).
781        let sr = 48000;
782        // Need > MAX_GATING_BLOCKS sub-blocks.  Each sub-block is 100 ms,
783        // so 101 blocks = 10.1 s → 48000 * 10.1 ≈ 484800 frames.
784        let duration_s = 11;
785        let num_frames = sr * duration_s;
786        let mut samples = vec![0.0f32; num_frames * 2];
787
788        for i in 0..num_frames {
789            let t = i as f64 / sr as f64;
790            let s = (2.0 * PI * 440.0 * t).sin() as f32 * 0.5;
791            samples[i * 2] = s;
792            samples[i * 2 + 1] = s;
793        }
794
795        let mut meter = EbuR128::new(2, sr as u32, Mode::I).unwrap();
796        meter.add_frames_f32(&samples).unwrap();
797
798        // Gating must still produce a valid result after overflow.
799        let lufs = meter.loudness_global().unwrap();
800        assert!(
801            lufs > -30.0 && lufs < 5.0,
802            "global loudness should be reasonable after overflow, got {lufs}"
803        );
804
805        let result = meter.gating_block_count_and_energy();
806        assert!(
807            result.is_some(),
808            "gating_block_count_and_energy should work after overflow"
809        );
810    }
811}