Skip to main content

resonant_analysis/
lufs.rs

1//! ITU-R BS.1770-4 integrated loudness (LUFS / LKFS) measurement.
2//!
3//! Applies K-weighting (a two-stage biquad cascade), computes mean-square
4//! levels over 400 ms gating blocks, then applies the absolute and relative
5//! gates defined in the standard to produce integrated loudness.
6
7extern crate alloc;
8use alloc::vec;
9use alloc::vec::Vec;
10
11use resonant_filters::biquad::{Biquad, BiquadCoeffs};
12use resonant_filters::{design, PolyphaseResampler};
13
14use crate::AnalysisError;
15
16/// Channel configuration for multi-channel loudness measurement.
17///
18/// Per ITU-R BS.1770-4, each channel is K-weighted independently; the gating
19/// operates on the weighted sum of per-channel mean-square levels.
20///
21/// Standard weights: L=1.0, R=1.0, C=1.0, LFE=0.0, Ls=√2, Rs=√2.
22#[derive(Debug, Clone)]
23pub struct ChannelConfig {
24    /// Per-channel gain weights (linear, not dB).
25    pub weights: Vec<f32>,
26}
27
28impl ChannelConfig {
29    /// Mono passthrough (weight = 1.0).
30    #[must_use]
31    pub fn mono() -> Self {
32        Self { weights: vec![1.0] }
33    }
34
35    /// Stereo per BS.1770-4 (L=1.0, R=1.0).
36    #[must_use]
37    pub fn stereo() -> Self {
38        Self {
39            weights: vec![1.0, 1.0],
40        }
41    }
42
43    /// 5.1 surround per BS.1770-4 channel order: L, R, C, LFE, Ls, Rs.
44    ///
45    /// LFE is excluded (weight 0.0). Surround channels Ls and Rs are weighted
46    /// at √2 ≈ 1.4142 (3 dB higher than front channels per the standard).
47    #[must_use]
48    pub fn surround_5_1() -> Self {
49        use core::f32::consts::SQRT_2;
50        Self {
51            weights: vec![1.0, 1.0, 1.0, 0.0, SQRT_2, SQRT_2],
52        }
53    }
54}
55
56/// Minimum loudness value returned for silence or near-silence.
57const SILENCE_LUFS: f32 = -120.0;
58
59/// Absolute gating threshold per ITU-R BS.1770-4 §2.7.
60const ABS_GATE_LUFS: f32 = -70.0;
61
62/// Relative gate offset: 10 LU below the absolute-gated mean.
63const REL_GATE_OFFSET_LU: f32 = 10.0;
64
65// K-weighting filter coefficients (ITU-R BS.1770-4 Annex 1).
66//
67// Stage 1 — high-shelf pre-filter: compensates for the acoustic effect of
68// sound diffracted around the human head (~+4 dB above 1.5 kHz).
69// Stage 2 — RLB high-pass: suppresses sub-bass content below ~40 Hz.
70
71const PRE_44100: BiquadCoeffs = BiquadCoeffs {
72    b0: 1.530_926_5_f32,
73    b1: -2.651_179_f32,
74    b2: 1.169_068_2_f32,
75    a1: -1.663_758_3_f32,
76    a2: 0.712_653_96_f32,
77};
78const RLB_44100: BiquadCoeffs = BiquadCoeffs {
79    b0: 1.0_f32,
80    b1: -2.0_f32,
81    b2: 1.0_f32,
82    a1: -1.988_378_9_f32,
83    a2: 0.988_520_47_f32,
84};
85const PRE_48000: BiquadCoeffs = BiquadCoeffs {
86    b0: 1.535_124_9_f32,
87    b1: -2.691_696_2_f32,
88    b2: 1.198_392_9_f32,
89    a1: -1.690_659_3_f32,
90    a2: 0.732_480_77_f32,
91};
92const RLB_48000: BiquadCoeffs = BiquadCoeffs {
93    b0: 1.0_f32,
94    b1: -2.0_f32,
95    b2: 1.0_f32,
96    a1: -1.990_047_5_f32,
97    a2: 0.990_072_25_f32,
98};
99
100/// ITU-R BS.1770-4 loudness analyser.
101///
102/// Applies K-weighting and computes gated integrated loudness (LUFS-I),
103/// momentary loudness (LUFS-M), short-term loudness (LUFS-S), and true-peak
104/// level (dBTP).
105///
106/// The K-weighting filter state persists across calls. Call [`reset`] between
107/// independent signals to avoid state bleed.
108///
109/// # Examples
110///
111/// ```
112/// use resonant_analysis::LufsAnalyser;
113///
114/// let mut analyser = LufsAnalyser::new(44100.0).unwrap();
115/// // Silence falls below the absolute gate — no blocks survive.
116/// let result = analyser.integrated_loudness(&vec![0.0_f32; 44100 * 5]);
117/// assert!(result.is_err());
118/// ```
119///
120/// [`reset`]: LufsAnalyser::reset
121pub struct LufsAnalyser {
122    sample_rate: f32,
123    pre: Biquad,
124    rlb: Biquad,
125}
126
127impl LufsAnalyser {
128    /// Creates a K-weighted analyser for the given sample rate.
129    ///
130    /// ITU-R BS.1770-4 Annex 1 coefficients are used for 44100 and 48000 Hz.
131    /// For 88200 and 96000 Hz, coefficients are derived from the same analog
132    /// prototype via bilinear transform using the filter design utilities.
133    ///
134    /// # Errors
135    ///
136    /// Returns [`AnalysisError::InvalidParameter`] for unsupported sample rates.
137    /// Supported rates: 44100, 48000, 88200, 96000 Hz.
138    ///
139    /// # Examples
140    ///
141    /// ```
142    /// use resonant_analysis::LufsAnalyser;
143    ///
144    /// assert!(LufsAnalyser::new(44100.0).is_ok());
145    /// assert!(LufsAnalyser::new(48000.0).is_ok());
146    /// assert!(LufsAnalyser::new(22050.0).is_err());
147    /// ```
148    pub fn new(sample_rate: f32) -> Result<Self, AnalysisError> {
149        let (pre, rlb) = kweight_coeffs(sample_rate)?;
150        Ok(Self {
151            sample_rate,
152            pre: Biquad::new(pre),
153            rlb: Biquad::new(rlb),
154        })
155    }
156
157    /// Integrated loudness (gated, LUFS-I) per ITU-R BS.1770-4.
158    ///
159    /// Applies the absolute gate (−70 LUFS) then the relative gate (−10 LU
160    /// below the absolute-gated mean). Input must be mono.
161    ///
162    /// # Errors
163    ///
164    /// Returns [`AnalysisError::EmptyInput`] if the input is empty, shorter than
165    /// one 400 ms block, or if all blocks are silenced by the absolute gate.
166    pub fn integrated_loudness(&mut self, samples: &[f32]) -> Result<f32, AnalysisError> {
167        if samples.is_empty() {
168            return Err(AnalysisError::EmptyInput);
169        }
170
171        let block_samples = block_len(self.sample_rate);
172        let hop_samples = hop_len(self.sample_rate);
173
174        let kw_samples: Vec<f32> = samples.iter().map(|&x| self.k_weight(x)).collect();
175
176        let block_levels = block_mean_sq(&kw_samples, block_samples, hop_samples);
177        if block_levels.is_empty() {
178            return Err(AnalysisError::EmptyInput);
179        }
180
181        // Absolute gate — discard blocks below −70 LUFS.
182        let abs_gate_ms = lufs_to_ms(ABS_GATE_LUFS);
183        let abs_gated: Vec<f32> = block_levels
184            .iter()
185            .copied()
186            .filter(|&z| z >= abs_gate_ms)
187            .collect();
188        if abs_gated.is_empty() {
189            return Err(AnalysisError::EmptyInput);
190        }
191
192        // Relative gate — discard blocks more than 10 LU below the gated mean.
193        let rel_gate_lufs = ms_to_lufs(mean_f32(&abs_gated)) - REL_GATE_OFFSET_LU;
194        let rel_gate_ms = lufs_to_ms(rel_gate_lufs);
195        let rel_gated: Vec<f32> = block_levels
196            .iter()
197            .copied()
198            .filter(|&z| z >= rel_gate_ms)
199            .collect();
200        if rel_gated.is_empty() {
201            return Err(AnalysisError::EmptyInput);
202        }
203
204        Ok(ms_to_lufs(mean_f32(&rel_gated)))
205    }
206
207    /// Momentary loudness (un-gated, 400 ms window, LUFS-M).
208    ///
209    /// Returns −120 dB for silence or empty input.
210    #[must_use]
211    pub fn momentary(&mut self, frame_400ms: &[f32]) -> f32 {
212        ms_to_lufs(self.kw_mean_sq(frame_400ms))
213    }
214
215    /// Short-term loudness (un-gated, 3 s window, LUFS-S).
216    ///
217    /// Returns −120 dB for silence or empty input.
218    #[must_use]
219    pub fn short_term(&mut self, frame_3s: &[f32]) -> f32 {
220        ms_to_lufs(self.kw_mean_sq(frame_3s))
221    }
222
223    /// True-peak level in dBTP (4× oversampled).
224    ///
225    /// Upsamples the signal 4× with a polyphase resampler before finding the
226    /// peak. This detects inter-sample peaks that may exceed 0 dBFS in the
227    /// continuous-time signal.
228    ///
229    /// Returns −120 dB for silence.
230    ///
231    /// # Errors
232    ///
233    /// Returns [`AnalysisError::EmptyInput`] for empty input.
234    pub fn true_peak_db(&self, samples: &[f32]) -> Result<f32, AnalysisError> {
235        if samples.is_empty() {
236            return Err(AnalysisError::EmptyInput);
237        }
238        let mut resampler =
239            PolyphaseResampler::new(4, 1).ok_or(AnalysisError::InvalidParameter {
240                name: "true_peak",
241                reason: "failed to construct 4× polyphase resampler",
242            })?;
243        let upsampled = resampler.process(samples);
244        let peak = upsampled.iter().map(|s| s.abs()).fold(0.0_f32, f32::max);
245        if peak <= 0.0 {
246            return Ok(SILENCE_LUFS);
247        }
248        Ok(20.0 * peak.log10())
249    }
250
251    /// Resets the K-weighting filter delay state.
252    ///
253    /// Call between independent analyses to prevent filter memory from one
254    /// signal influencing the next.
255    pub fn reset(&mut self) {
256        let pre_coeffs = *self.pre.coeffs();
257        let rlb_coeffs = *self.rlb.coeffs();
258        self.pre = Biquad::new(pre_coeffs);
259        self.rlb = Biquad::new(rlb_coeffs);
260    }
261
262    /// Integrated loudness (LUFS-I) from interleaved multi-channel audio.
263    ///
264    /// Each channel is K-weighted independently. The gating operates on the
265    /// weighted sum of per-channel mean-square levels per ITU-R BS.1770-4 §2:
266    ///
267    /// `z_j = Σ G_c · mean_sq(channel_c, block_j)`
268    ///
269    /// # Errors
270    ///
271    /// Returns [`AnalysisError::EmptyInput`] if the input is empty, shorter than
272    /// one 400 ms block, or all blocks fall below the absolute gate (silence).
273    /// Returns [`AnalysisError::InvalidParameter`] if the channel config has no
274    /// weights or the sample count is not a multiple of the channel count.
275    pub fn integrated_loudness_multichannel(
276        &mut self,
277        interleaved: &[f32],
278        config: &ChannelConfig,
279    ) -> Result<f32, AnalysisError> {
280        let n_channels = config.weights.len();
281        if n_channels == 0 {
282            return Err(AnalysisError::InvalidParameter {
283                name: "config",
284                reason: "channel config has no weights",
285            });
286        }
287        if interleaved.is_empty() {
288            return Err(AnalysisError::EmptyInput);
289        }
290        if interleaved.len() % n_channels != 0 {
291            return Err(AnalysisError::InvalidParameter {
292                name: "interleaved",
293                reason: "sample count is not a multiple of channel count",
294            });
295        }
296
297        let n_frames = interleaved.len() / n_channels;
298        let block_samples = block_len(self.sample_rate);
299        let hop_samples = hop_len(self.sample_rate);
300
301        if n_frames < block_samples {
302            return Err(AnalysisError::EmptyInput);
303        }
304
305        let (pre_coeffs, rlb_coeffs) = kweight_coeffs(self.sample_rate)?;
306        let mut channel_filters: Vec<(Biquad, Biquad)> = (0..n_channels)
307            .map(|_| (Biquad::new(pre_coeffs), Biquad::new(rlb_coeffs)))
308            .collect();
309
310        // K-weight each channel independently.
311        let mut channel_kw_samples: Vec<Vec<f32>> = (0..n_channels)
312            .map(|_| Vec::with_capacity(n_frames))
313            .collect();
314        for frame in interleaved.chunks_exact(n_channels) {
315            for ((ch_kw_samples, (pre, rlb)), &sample) in channel_kw_samples
316                .iter_mut()
317                .zip(channel_filters.iter_mut())
318                .zip(frame.iter())
319            {
320                ch_kw_samples.push(rlb.process_sample(pre.process_sample(sample)));
321            }
322        }
323
324        // z_j = Σ_c G_c · ms_c_j  (BS.1770-4 eq. 2)
325        let n_blocks = (n_frames - block_samples) / hop_samples + 1;
326        let block_levels: Vec<f32> = (0..n_blocks)
327            .map(|block_idx| {
328                let block_start = block_idx * hop_samples;
329                config
330                    .weights
331                    .iter()
332                    .zip(channel_kw_samples.iter())
333                    .map(|(&weight, ch_kw)| {
334                        let block_slice = &ch_kw[block_start..block_start + block_samples];
335                        let block_ms =
336                            block_slice.iter().map(|&x| x * x).sum::<f32>() / block_samples as f32;
337                        weight * block_ms
338                    })
339                    .sum()
340            })
341            .collect();
342
343        if block_levels.is_empty() {
344            return Err(AnalysisError::EmptyInput);
345        }
346
347        // Absolute gate — discard blocks below −70 LUFS.
348        let abs_gate_ms = lufs_to_ms(ABS_GATE_LUFS);
349        let abs_gated: Vec<f32> = block_levels
350            .iter()
351            .copied()
352            .filter(|&z| z >= abs_gate_ms)
353            .collect();
354        if abs_gated.is_empty() {
355            return Err(AnalysisError::EmptyInput);
356        }
357
358        // Relative gate — discard blocks more than 10 LU below the absolute-gated mean.
359        let rel_gate_lufs = ms_to_lufs(mean_f32(&abs_gated)) - REL_GATE_OFFSET_LU;
360        let rel_gate_ms = lufs_to_ms(rel_gate_lufs);
361        let rel_gated: Vec<f32> = block_levels
362            .iter()
363            .copied()
364            .filter(|&z| z >= rel_gate_ms)
365            .collect();
366        if rel_gated.is_empty() {
367            return Err(AnalysisError::EmptyInput);
368        }
369
370        Ok(ms_to_lufs(mean_f32(&rel_gated)))
371    }
372
373    #[inline]
374    fn k_weight(&mut self, x: f32) -> f32 {
375        self.rlb.process_sample(self.pre.process_sample(x))
376    }
377
378    fn kw_mean_sq(&mut self, samples: &[f32]) -> f32 {
379        if samples.is_empty() {
380            return 0.0;
381        }
382        let sum_sq: f32 = samples
383            .iter()
384            .map(|&x| {
385                let y = self.k_weight(x);
386                y * y
387            })
388            .sum();
389        sum_sq / samples.len() as f32
390    }
391}
392
393/// Selects K-weighting filter coefficients for the given sample rate.
394fn kweight_coeffs(sr: f32) -> Result<(BiquadCoeffs, BiquadCoeffs), AnalysisError> {
395    match sr.round() as u32 {
396        44100 => Ok((PRE_44100, RLB_44100)),
397        48000 => Ok((PRE_48000, RLB_48000)),
398        88200 | 96000 => {
399            // Analog prototype: +4 dB high-shelf at 1681.97 Hz (pre-filter);
400            // 2nd-order Butterworth HP at 38.135 Hz (RLB high-pass).
401            let pre =
402                design::shelving_high(4.0, 1681.97, sr).ok_or(AnalysisError::InvalidParameter {
403                    name: "sample_rate",
404                    reason: "K-weighting pre-filter design failed",
405                })?;
406            let rlb = design::butterworth_highpass(38.135_f64, f64::from(sr)).ok_or(
407                AnalysisError::InvalidParameter {
408                    name: "sample_rate",
409                    reason: "K-weighting RLB high-pass design failed",
410                },
411            )?;
412            Ok((pre, rlb))
413        }
414        _ => Err(AnalysisError::InvalidParameter {
415            name: "sample_rate",
416            reason: "supported rates: 44100, 48000, 88200, 96000",
417        }),
418    }
419}
420
421/// Length of one 400 ms gating block in samples.
422fn block_len(sr: f32) -> usize {
423    (sr * 0.4).round() as usize
424}
425
426/// Hop size (100 ms) giving 75% overlap between adjacent blocks.
427fn hop_len(sr: f32) -> usize {
428    (sr * 0.1).round() as usize
429}
430
431/// Computes mean-square over each overlapping `block`-length window.
432fn block_mean_sq(signal: &[f32], block: usize, hop: usize) -> Vec<f32> {
433    if block == 0 || hop == 0 || signal.len() < block {
434        return vec![];
435    }
436    let n_blocks = (signal.len() - block) / hop + 1;
437    (0..n_blocks)
438        .map(|i| {
439            let block_slice = &signal[i * hop..i * hop + block];
440            block_slice.iter().map(|&x| x * x).sum::<f32>() / block as f32
441        })
442        .collect()
443}
444
445/// Converts linear mean-square to LUFS: `L = −0.691 + 10 log₁₀(mean_sq)`.
446fn ms_to_lufs(mean_sq: f32) -> f32 {
447    if mean_sq <= 0.0 {
448        SILENCE_LUFS
449    } else {
450        (-0.691 + 10.0 * mean_sq.log10()).max(SILENCE_LUFS)
451    }
452}
453
454/// Converts a LUFS level to the corresponding linear mean-square.
455fn lufs_to_ms(lufs: f32) -> f32 {
456    10f32.powf((lufs + 0.691) / 10.0)
457}
458
459fn mean_f32(v: &[f32]) -> f32 {
460    v.iter().sum::<f32>() / v.len() as f32
461}
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466    use core::f32::consts::PI;
467
468    const SR: f32 = 44100.0;
469
470    fn sine(freq: f32, amplitude: f32, num_samples: usize, sr: f32) -> Vec<f32> {
471        (0..num_samples)
472            .map(|i| amplitude * (2.0 * PI * freq * i as f32 / sr).sin())
473            .collect()
474    }
475
476    /// Constructs a `LufsAnalyser`; panics if the sample rate is unsupported.
477    fn make(sr: f32) -> LufsAnalyser {
478        match LufsAnalyser::new(sr) {
479            Ok(analyser) => analyser,
480            Err(e) => panic!("LufsAnalyser::new({sr}) failed: {e}"),
481        }
482    }
483
484    #[test]
485    fn new_44100_succeeds() {
486        assert!(LufsAnalyser::new(44100.0).is_ok());
487    }
488
489    #[test]
490    fn new_48000_succeeds() {
491        assert!(LufsAnalyser::new(48000.0).is_ok());
492    }
493
494    #[test]
495    fn new_88200_succeeds() {
496        assert!(LufsAnalyser::new(88200.0).is_ok());
497    }
498
499    #[test]
500    fn new_96000_succeeds() {
501        assert!(LufsAnalyser::new(96000.0).is_ok());
502    }
503
504    #[test]
505    fn unsupported_rate_returns_error() {
506        assert!(LufsAnalyser::new(22050.0).is_err());
507        assert!(LufsAnalyser::new(16000.0).is_err());
508        assert!(LufsAnalyser::new(0.0).is_err());
509    }
510
511    #[test]
512    fn empty_input_returns_error() {
513        let mut analyser = make(SR);
514        assert!(matches!(
515            analyser.integrated_loudness(&[]),
516            Err(AnalysisError::EmptyInput)
517        ));
518    }
519
520    #[test]
521    fn silence_gated_out() {
522        let mut analyser = make(SR);
523        let result = analyser.integrated_loudness(&vec![0.0_f32; 44100 * 5]);
524        assert!(matches!(result, Err(AnalysisError::EmptyInput)));
525    }
526
527    #[test]
528    fn too_short_for_one_block() {
529        let mut analyser = make(SR);
530        let result = analyser.integrated_loudness(&[0.1_f32; 100]);
531        assert!(matches!(result, Err(AnalysisError::EmptyInput)));
532    }
533
534    // A 1 kHz sine at amplitude 0.10 gives approximately −23 LUFS.
535    // The K-weighting gain at 1 kHz (~+0.69 dB) and the −0.691 dB formula
536    // offset together place amplitude 0.10 (−20 dBFS peak) close to −23 LUFS.
537    #[test]
538    fn integrated_lufs_calibration_tone() {
539        let num_samples = (SR * 5.0) as usize;
540        let sig = sine(1000.0, 0.10, num_samples, SR);
541        let mut analyser = make(SR);
542        match analyser.integrated_loudness(&sig) {
543            Ok(lufs) => assert!(
544                (lufs - (-23.0)).abs() < 0.2,
545                "expected ≈ −23 LUFS, got {lufs:.3}"
546            ),
547            Err(e) => panic!("unexpected error: {e}"),
548        }
549    }
550
551    #[test]
552    fn integrated_lufs_6lu_per_6db() {
553        let num_samples = (SR * 5.0) as usize;
554        let signal_quiet = sine(1000.0, 0.10, num_samples, SR);
555        let signal_loud = sine(1000.0, 0.20, num_samples, SR);
556        let mut analyser = make(SR);
557        let lufs_quiet = match analyser.integrated_loudness(&signal_quiet) {
558            Ok(lufs) => lufs,
559            Err(e) => panic!("quiet signal error: {e}"),
560        };
561        analyser.reset();
562        let lufs_loud = match analyser.integrated_loudness(&signal_loud) {
563            Ok(lufs) => lufs,
564            Err(e) => panic!("loud signal error: {e}"),
565        };
566        assert!(
567            (lufs_loud - lufs_quiet - 6.02).abs() < 0.05,
568            "doubling amplitude should give +6.02 LU, got {:.3}",
569            lufs_loud - lufs_quiet
570        );
571    }
572
573    #[test]
574    fn true_peak_full_scale_sine_near_zero_dbtp() {
575        let num_samples = SR as usize;
576        let sig: Vec<f32> = (0..num_samples)
577            .map(|i| (2.0 * PI * 997.0 * i as f32 / SR).sin())
578            .collect();
579        let analyser = make(SR);
580        match analyser.true_peak_db(&sig) {
581            Ok(true_peak) => assert!(
582                true_peak.abs() < 0.5,
583                "full-scale 997 Hz sine ≈ 0 dBTP, got {true_peak:.3}"
584            ),
585            Err(e) => panic!("unexpected error: {e}"),
586        }
587    }
588
589    #[test]
590    fn true_peak_empty_returns_error() {
591        let analyser = make(SR);
592        assert!(matches!(
593            analyser.true_peak_db(&[]),
594            Err(AnalysisError::EmptyInput)
595        ));
596    }
597
598    #[test]
599    fn momentary_reasonable_for_sine() {
600        let block_samples = block_len(SR);
601        let sig = sine(1000.0, 0.10, block_samples, SR);
602        let momentary_lufs = {
603            let mut analyser = make(SR);
604            analyser.momentary(&sig)
605        };
606        assert!(
607            momentary_lufs > -35.0 && momentary_lufs < -15.0,
608            "momentary LUFS out of range: {momentary_lufs:.2}"
609        );
610    }
611
612    #[test]
613    fn short_term_reasonable_for_sine() {
614        let num_samples = (SR * 3.0) as usize;
615        let sig = sine(1000.0, 0.10, num_samples, SR);
616        let short_term_lufs = {
617            let mut analyser = make(SR);
618            analyser.short_term(&sig)
619        };
620        assert!(
621            short_term_lufs > -35.0 && short_term_lufs < -15.0,
622            "short-term LUFS out of range: {short_term_lufs:.2}"
623        );
624    }
625
626    #[test]
627    fn reset_clears_filter_state() {
628        let block_samples = block_len(SR);
629        let pre_warm = sine(100.0, 1.0, block_samples, SR);
630        let test_sig = sine(1000.0, 0.10, block_samples, SR);
631
632        let lufs_after_reset = {
633            let mut analyser = make(SR);
634            let _ = analyser.momentary(&pre_warm);
635            analyser.reset();
636            analyser.momentary(&test_sig)
637        };
638        let lufs_fresh = {
639            let mut analyser = make(SR);
640            analyser.momentary(&test_sig)
641        };
642        assert!(
643            (lufs_after_reset - lufs_fresh).abs() < 1e-5,
644            "reset should give same result as fresh: {lufs_after_reset:.6} vs {lufs_fresh:.6}"
645        );
646    }
647
648    #[test]
649    fn block_mean_sq_constant_signal() {
650        let sig = vec![0.5_f32; 100];
651        let block_levels = block_mean_sq(&sig, 50, 25);
652        assert_eq!(block_levels.len(), 3);
653        for level in &block_levels {
654            assert!((level - 0.25).abs() < 1e-6, "expected 0.25, got {level}");
655        }
656    }
657
658    #[test]
659    fn block_mean_sq_too_short() {
660        assert!(block_mean_sq(&[1.0_f32; 10], 50, 25).is_empty());
661    }
662
663    #[test]
664    fn ms_to_lufs_known_value() {
665        // L = -0.691 + 10*log10(0.5) = -0.691 - 3.0103 ≈ -3.701
666        let lufs = ms_to_lufs(0.5);
667        assert!((lufs - (-3.701)).abs() < 0.001, "got {lufs:.4}");
668    }
669
670    #[test]
671    fn ms_to_lufs_silence() {
672        assert_eq!(ms_to_lufs(0.0), SILENCE_LUFS);
673        assert_eq!(ms_to_lufs(-1.0), SILENCE_LUFS);
674    }
675
676    #[test]
677    fn lufs_to_ms_roundtrip() {
678        let lufs = -23.0_f32;
679        let mean_sq = lufs_to_ms(lufs);
680        let roundtripped_lufs = ms_to_lufs(mean_sq);
681        assert!(
682            (roundtripped_lufs - lufs).abs() < 0.001,
683            "roundtrip: {lufs} → {roundtripped_lufs:.4}"
684        );
685    }
686
687    // --- integrated_loudness_multichannel ---
688
689    #[test]
690    fn multichannel_empty_input_returns_error() {
691        let mut analyser = make(SR);
692        assert!(matches!(
693            analyser.integrated_loudness_multichannel(&[], &ChannelConfig::stereo()),
694            Err(AnalysisError::EmptyInput)
695        ));
696    }
697
698    #[test]
699    fn multichannel_empty_config_returns_error() {
700        let mut analyser = make(SR);
701        let config = ChannelConfig { weights: vec![] };
702        assert!(matches!(
703            analyser.integrated_loudness_multichannel(&[0.1_f32; 100], &config),
704            Err(AnalysisError::InvalidParameter { .. })
705        ));
706    }
707
708    #[test]
709    fn multichannel_mismatched_samples_returns_error() {
710        let mut analyser = make(SR);
711        // 3 samples is not a multiple of 2 channels.
712        assert!(matches!(
713            analyser.integrated_loudness_multichannel(&[0.1_f32; 3], &ChannelConfig::stereo()),
714            Err(AnalysisError::InvalidParameter { .. })
715        ));
716    }
717
718    #[test]
719    fn multichannel_mono_matches_integrated_loudness() {
720        // ChannelConfig::mono() wrapping a mono signal must give the same LUFS
721        // as integrated_loudness() on the same samples.
722        let num_samples = (SR * 5.0) as usize;
723        let sig = sine(1000.0, 0.10, num_samples, SR);
724        let mut analyser_mono = make(SR);
725        let mut analyser_mc = make(SR);
726        let lufs_mono = analyser_mono
727            .integrated_loudness(&sig)
728            .unwrap_or_else(|e| panic!("integrated_loudness error: {e}"));
729        let lufs_mc = analyser_mc
730            .integrated_loudness_multichannel(&sig, &ChannelConfig::mono())
731            .unwrap_or_else(|e| panic!("multichannel mono error: {e}"));
732        assert!(
733            (lufs_mono - lufs_mc).abs() < 0.01,
734            "mono config should match integrated_loudness: {lufs_mono:.3} vs {lufs_mc:.3}"
735        );
736    }
737
738    #[test]
739    fn stereo_identical_channels_is_three_lu_above_mono() {
740        // BS.1770-4 with G_L=G_R=1.0: z_stereo = 2 · z_mono →
741        // LUFS_stereo = LUFS_mono + 10·log10(2) ≈ LUFS_mono + 3.01 LU.
742        let num_samples = (SR * 5.0) as usize;
743        let mono = sine(1000.0, 0.10, num_samples, SR);
744        let stereo: Vec<f32> = mono.iter().flat_map(|&s| [s, s]).collect();
745
746        let mut analyser_mono = make(SR);
747        let mut analyser_stereo = make(SR);
748
749        let lufs_mono = analyser_mono
750            .integrated_loudness(&mono)
751            .unwrap_or_else(|e| panic!("mono error: {e}"));
752        let lufs_stereo = analyser_stereo
753            .integrated_loudness_multichannel(&stereo, &ChannelConfig::stereo())
754            .unwrap_or_else(|e| panic!("stereo error: {e}"));
755
756        let diff = lufs_stereo - lufs_mono;
757        assert!(
758            (diff - 3.01).abs() < 0.1,
759            "identical stereo should be +3.01 LU above mono, got {diff:.3} LU"
760        );
761    }
762
763    #[test]
764    fn surround_5_1_lfe_only_is_gated_out() {
765        // LFE channel (index 3) has weight 0.0 — even a loud LFE signal must
766        // result in all blocks being silenced by the absolute gate.
767        let num_samples = (SR * 5.0) as usize;
768        let lfe_signal = sine(60.0, 0.5, num_samples, SR);
769        // Interleave: L=0, R=0, C=0, LFE=signal, Ls=0, Rs=0
770        let interleaved: Vec<f32> = (0..num_samples)
771            .flat_map(|i| [0.0, 0.0, 0.0, lfe_signal[i], 0.0, 0.0])
772            .collect();
773
774        let mut analyser = make(SR);
775        assert!(
776            matches!(
777                analyser
778                    .integrated_loudness_multichannel(&interleaved, &ChannelConfig::surround_5_1()),
779                Err(AnalysisError::EmptyInput)
780            ),
781            "LFE-only 5.1 should be gated out"
782        );
783    }
784
785    #[test]
786    fn stereo_silence_gated_out() {
787        let num_samples = (SR * 5.0) as usize;
788        let stereo = vec![0.0_f32; num_samples * 2];
789        let mut analyser = make(SR);
790        assert!(matches!(
791            analyser.integrated_loudness_multichannel(&stereo, &ChannelConfig::stereo()),
792            Err(AnalysisError::EmptyInput)
793        ));
794    }
795
796    #[test]
797    fn stereo_too_short_for_one_block_returns_error() {
798        let mut analyser = make(SR);
799        let stereo = vec![0.1_f32; 200]; // 100 stereo frames, well under 400 ms
800        assert!(matches!(
801            analyser.integrated_loudness_multichannel(&stereo, &ChannelConfig::stereo()),
802            Err(AnalysisError::EmptyInput)
803        ));
804    }
805}