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