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