Skip to main content

sim_lib_sound_render/
loudness.rs

1use std::{error::Error, fmt};
2
3mod dsp;
4mod validate;
5
6use dsp::{measure_true_peak, weight_channels};
7use validate::{validate_normalization, validate_spec};
8
9const LOUDNESS_OFFSET_DB: f64 = -0.691;
10const MOMENTARY_SECONDS: f64 = 0.400;
11const MOMENTARY_STEP_SECONDS: f64 = 0.100;
12
13/// Semantic channel position used by ITU-R BS.1770 energy weighting.
14#[derive(Clone, Copy, Debug, PartialEq, Eq)]
15pub enum LoudnessChannel {
16    /// Mono or front-center program channel.
17    Center,
18    /// Front-left channel.
19    Left,
20    /// Front-right channel.
21    Right,
22    /// Left surround channel, weighted by 1.41 in BS.1770.
23    LeftSurround,
24    /// Right surround channel, weighted by 1.41 in BS.1770.
25    RightSurround,
26    /// Low-frequency-effects channel, excluded by BS.1770.
27    Lfe,
28}
29
30/// Ordered interleaved channel layout for loudness measurement.
31#[derive(Clone, Debug, PartialEq, Eq)]
32pub struct LoudnessLayout {
33    channels: Vec<LoudnessChannel>,
34}
35
36impl LoudnessLayout {
37    /// Builds a bounded nonempty layout.
38    pub fn new(channels: Vec<LoudnessChannel>) -> Result<Self, LoudnessError> {
39        if channels.is_empty() || channels.len() > 32 {
40            return Err(LoudnessError::InvalidPolicy {
41                field: "channel layout",
42                reason: "must contain between one and 32 channels",
43            });
44        }
45        Ok(Self { channels })
46    }
47
48    /// Standard one-channel program layout.
49    pub fn mono() -> Self {
50        Self {
51            channels: vec![LoudnessChannel::Center],
52        }
53    }
54
55    /// Standard left/right program layout.
56    pub fn stereo() -> Self {
57        Self {
58            channels: vec![LoudnessChannel::Left, LoudnessChannel::Right],
59        }
60    }
61
62    /// Standard 5.1 order: left, right, center, LFE, left surround, right surround.
63    pub fn five_point_one() -> Self {
64        Self {
65            channels: vec![
66                LoudnessChannel::Left,
67                LoudnessChannel::Right,
68                LoudnessChannel::Center,
69                LoudnessChannel::Lfe,
70                LoudnessChannel::LeftSurround,
71                LoudnessChannel::RightSurround,
72            ],
73        }
74    }
75
76    /// Returns the ordered interleaved channels.
77    pub fn channels(&self) -> &[LoudnessChannel] {
78        &self.channels
79    }
80}
81
82/// Frequency weighting applied before gated loudness integration.
83#[derive(Clone, Copy, Debug, PartialEq, Eq)]
84pub enum FrequencyWeighting {
85    /// Two-stage ITU-R BS.1770 K-weighting (shelf plus RLB high-pass).
86    ItuRBs1770K,
87    /// No weighting, retained for calibration and controlled comparisons.
88    Flat,
89}
90
91/// Block-gating policy for integrated loudness.
92#[derive(Clone, Copy, Debug, PartialEq)]
93pub enum GatingPolicy {
94    /// EBU R128 absolute `-70 LUFS` and relative `-10 LU` gates.
95    EbuR128,
96    /// Explicit absolute and relative gate thresholds.
97    AbsoluteRelative {
98        /// Absolute block threshold in LUFS.
99        absolute_lufs: f64,
100        /// Relative threshold below absolute-gated program loudness, in LU.
101        relative_lu: f64,
102    },
103    /// Integrate all complete momentary blocks without gating.
104    None,
105}
106
107/// Bandlimited true-peak interpolation policy.
108#[derive(Clone, Copy, Debug, PartialEq, Eq)]
109pub struct TruePeakPolicy {
110    /// Integer oversampling factor; BS.1770 measurement conventionally uses four.
111    pub oversample_factor: usize,
112    /// Even Blackman-windowed sinc length.
113    pub taps: usize,
114    /// Hard interpolation multiply-accumulate ceiling.
115    pub max_work: u64,
116}
117
118impl Default for TruePeakPolicy {
119    fn default() -> Self {
120        Self {
121            oversample_factor: 4,
122            taps: 24,
123            max_work: 100_000_000,
124        }
125    }
126}
127
128/// Complete bounded policy for EBU/ITU loudness and true-peak measurement.
129#[derive(Clone, Debug, PartialEq)]
130pub struct LoudnessSpec {
131    /// PCM sample rate in hertz.
132    pub sample_rate_hz: u32,
133    /// Ordered interleaved channel layout.
134    pub layout: LoudnessLayout,
135    /// Frequency weighting applied independently to every channel.
136    pub frequency_weighting: FrequencyWeighting,
137    /// Integrated-loudness block gate.
138    pub gating: GatingPolicy,
139    /// Bandlimited true-peak interpolation policy.
140    pub true_peak: TruePeakPolicy,
141    /// Hard input-frame ceiling.
142    pub max_frames: usize,
143}
144
145impl Default for LoudnessSpec {
146    fn default() -> Self {
147        Self {
148            sample_rate_hz: 48_000,
149            layout: LoudnessLayout::stereo(),
150            frequency_weighting: FrequencyWeighting::ItuRBs1770K,
151            gating: GatingPolicy::EbuR128,
152            true_peak: TruePeakPolicy::default(),
153            max_frames: 16_777_216,
154        }
155    }
156}
157
158/// One 400 ms EBU momentary block.
159#[derive(Clone, Debug, PartialEq)]
160pub struct MomentaryLoudness {
161    /// First whole PCM frame in the block.
162    pub start_frame: usize,
163    /// BS.1770 channel-weighted mean-square energy.
164    pub mean_square: f64,
165    /// Loudness in LUFS, or `None` for digital silence.
166    pub lufs: Option<f64>,
167}
168
169/// Sample-peak and interpolated true-peak evidence.
170#[derive(Clone, Debug, PartialEq)]
171pub struct TruePeakReport {
172    /// Largest absolute input sample.
173    pub sample_peak: f64,
174    /// Largest absolute bandlimited interpolated sample.
175    pub true_peak: f64,
176    /// True peak in dBTP, or `None` for digital silence.
177    pub true_peak_dbtp: Option<f64>,
178    /// Integer interpolation factor actually used.
179    pub oversample_factor: usize,
180    /// Interpolation multiply-accumulates charged by the report.
181    pub work_units: u64,
182}
183
184/// Standards-named integrated, momentary, gating, and true-peak evidence.
185#[derive(Clone, Debug, PartialEq)]
186pub struct LoudnessReport {
187    /// Final gated integrated loudness in LUFS, or `None` when no block survives.
188    pub integrated_lufs: Option<f64>,
189    /// Loudness after the absolute gate and before the relative gate.
190    pub absolute_gated_lufs: Option<f64>,
191    /// Effective absolute gate in LUFS, when gating is enabled.
192    pub absolute_gate_lufs: Option<f64>,
193    /// Effective program-relative gate in LUFS, when it can be derived.
194    pub relative_gate_lufs: Option<f64>,
195    /// Complete 400 ms blocks at 100 ms spacing.
196    pub momentary: Vec<MomentaryLoudness>,
197    /// Blocks admitted by the final gate.
198    pub gated_blocks: usize,
199    /// Interpolated peak evidence over the unweighted source PCM.
200    pub true_peak: TruePeakReport,
201    /// Exact measurement policy.
202    pub spec: LoudnessSpec,
203}
204
205/// Target and safety ceiling for transparent loudness normalization.
206#[derive(Clone, Copy, Debug, PartialEq)]
207pub struct NormalizationSpec {
208    /// Requested integrated output loudness in LUFS.
209    pub target_lufs: f64,
210    /// Review ceiling in dBTP; it is reported, never enforced by hidden limiting.
211    pub max_true_peak_dbtp: f64,
212    /// Maximum absolute gain change the caller permits, in decibels.
213    pub max_abs_gain_db: f64,
214}
215
216/// Normalized PCM plus fully visible gain, ceiling, and clipping evidence.
217#[derive(Clone, Debug, PartialEq)]
218pub struct NormalizationReport {
219    /// Gain-adjusted float PCM; samples are not clipped or limited.
220    pub samples: Vec<f32>,
221    /// Measurement before gain.
222    pub input: LoudnessReport,
223    /// Measurement after the exact applied gain.
224    pub output: LoudnessReport,
225    /// Gain implied by target minus measured integrated loudness.
226    pub requested_gain_db: f64,
227    /// Gain actually applied after the explicit gain bound.
228    pub applied_gain_db: f64,
229    /// Whether `max_abs_gain_db` constrained the requested gain.
230    pub gain_limited: bool,
231    /// Whether measured output true peak exceeds the review ceiling.
232    pub true_peak_ceiling_exceeded: bool,
233    /// Output float samples outside `[-1, 1]`.
234    pub clipped_samples: usize,
235}
236
237/// Invalid loudness input, bound, or standards policy.
238#[derive(Clone, Debug, PartialEq, Eq)]
239pub enum LoudnessError {
240    /// A named policy field violated its finite definition.
241    InvalidPolicy {
242        /// Rejected field.
243        field: &'static str,
244        /// Stable reason.
245        reason: &'static str,
246    },
247    /// Interleaved samples ended mid-frame.
248    MisalignedInput,
249    /// A source sample was NaN or infinite.
250    NonFiniteSample {
251        /// Zero-based interleaved sample offset.
252        index: usize,
253    },
254    /// Input exceeded the declared frame bound.
255    FrameLimit {
256        /// Whole frames supplied.
257        supplied: usize,
258        /// Maximum admitted frames.
259        maximum: usize,
260    },
261    /// True-peak interpolation exceeded its deterministic work ceiling.
262    WorkLimit {
263        /// Required multiply-accumulates.
264        required: u64,
265        /// Policy ceiling.
266        maximum: u64,
267    },
268    /// Normalization cannot derive gain from digital silence or a fully gated signal.
269    UndefinedIntegratedLoudness,
270    /// Size or work arithmetic overflowed.
271    SizeOverflow,
272}
273
274impl fmt::Display for LoudnessError {
275    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
276        match self {
277            Self::InvalidPolicy { field, reason } => {
278                write!(f, "invalid loudness {field}: {reason}")
279            }
280            Self::MisalignedInput => write!(f, "interleaved loudness input ends mid-frame"),
281            Self::NonFiniteSample { index } => write!(f, "loudness sample {index} is not finite"),
282            Self::FrameLimit { supplied, maximum } => {
283                write!(
284                    f,
285                    "loudness input has {supplied} frames, exceeding {maximum}"
286                )
287            }
288            Self::WorkLimit { required, maximum } => {
289                write!(
290                    f,
291                    "true-peak interpolation needs {required} work, exceeding {maximum}"
292                )
293            }
294            Self::UndefinedIntegratedLoudness => {
295                write!(f, "integrated loudness is undefined for this signal")
296            }
297            Self::SizeOverflow => write!(f, "loudness size arithmetic overflowed"),
298        }
299    }
300}
301
302impl Error for LoudnessError {}
303
304/// Measures complete 400 ms momentary blocks, gated integrated loudness, and
305/// bandlimited true peak under one retained policy.
306pub fn measure_loudness(
307    input: &[f32],
308    spec: LoudnessSpec,
309) -> Result<LoudnessReport, LoudnessError> {
310    validate_spec(&spec)?;
311    let channels = spec.layout.channels.len();
312    if !input.len().is_multiple_of(channels) {
313        return Err(LoudnessError::MisalignedInput);
314    }
315    for (index, sample) in input.iter().copied().enumerate() {
316        if !sample.is_finite() {
317            return Err(LoudnessError::NonFiniteSample { index });
318        }
319    }
320    let frames = input.len() / channels;
321    if frames > spec.max_frames {
322        return Err(LoudnessError::FrameLimit {
323            supplied: frames,
324            maximum: spec.max_frames,
325        });
326    }
327    let weighted = weight_channels(input, &spec);
328    let momentary = momentary_blocks(&weighted, &spec)?;
329    let (
330        absolute_gate_lufs,
331        relative_gate_lufs,
332        absolute_gated_lufs,
333        integrated_lufs,
334        gated_blocks,
335    ) = integrate_blocks(&momentary, spec.gating);
336    let true_peak = measure_true_peak(input, &spec)?;
337    Ok(LoudnessReport {
338        integrated_lufs,
339        absolute_gated_lufs,
340        absolute_gate_lufs,
341        relative_gate_lufs,
342        momentary,
343        gated_blocks,
344        true_peak,
345        spec,
346    })
347}
348
349/// Applies one visible scalar gain to reach the requested loudness, then
350/// remeasures without clipping, limiting, or concealing a true-peak violation.
351pub fn normalize_loudness(
352    input: &[f32],
353    loudness: LoudnessSpec,
354    normalization: NormalizationSpec,
355) -> Result<NormalizationReport, LoudnessError> {
356    validate_normalization(normalization)?;
357    let before = measure_loudness(input, loudness.clone())?;
358    let integrated = before
359        .integrated_lufs
360        .ok_or(LoudnessError::UndefinedIntegratedLoudness)?;
361    let requested_gain_db = normalization.target_lufs - integrated;
362    let applied_gain_db = requested_gain_db.clamp(
363        -normalization.max_abs_gain_db,
364        normalization.max_abs_gain_db,
365    );
366    let gain_limited = (applied_gain_db - requested_gain_db).abs() > 1e-12;
367    let gain = 10.0f64.powf(applied_gain_db / 20.0);
368    let samples = input
369        .iter()
370        .map(|sample| (f64::from(*sample) * gain) as f32)
371        .collect::<Vec<_>>();
372    let clipped_samples = samples.iter().filter(|sample| sample.abs() > 1.0).count();
373    let output = measure_loudness(&samples, loudness)?;
374    let true_peak_ceiling_exceeded = output
375        .true_peak
376        .true_peak_dbtp
377        .is_some_and(|peak| peak > normalization.max_true_peak_dbtp);
378    Ok(NormalizationReport {
379        samples,
380        input: before,
381        output,
382        requested_gain_db,
383        applied_gain_db,
384        gain_limited,
385        true_peak_ceiling_exceeded,
386        clipped_samples,
387    })
388}
389
390fn momentary_blocks(
391    weighted: &[f64],
392    spec: &LoudnessSpec,
393) -> Result<Vec<MomentaryLoudness>, LoudnessError> {
394    let channels = spec.layout.channels.len();
395    let frames = weighted.len() / channels;
396    let window = (f64::from(spec.sample_rate_hz) * MOMENTARY_SECONDS).round() as usize;
397    let step = (f64::from(spec.sample_rate_hz) * MOMENTARY_STEP_SECONDS).round() as usize;
398    if frames < window {
399        return Ok(Vec::new());
400    }
401    let count = (frames - window) / step + 1;
402    let mut blocks = Vec::with_capacity(count);
403    for block in 0..count {
404        let start = block.checked_mul(step).ok_or(LoudnessError::SizeOverflow)?;
405        let mut energy = 0.0;
406        for (channel, position) in spec.layout.channels.iter().enumerate() {
407            let channel_energy = (start..start + window)
408                .map(|frame| weighted[frame * channels + channel].powi(2))
409                .sum::<f64>()
410                / window as f64;
411            energy += channel_weight(*position) * channel_energy;
412        }
413        blocks.push(MomentaryLoudness {
414            start_frame: start,
415            mean_square: energy,
416            lufs: loudness_level(energy),
417        });
418    }
419    Ok(blocks)
420}
421
422#[allow(clippy::type_complexity)]
423fn integrate_blocks(
424    blocks: &[MomentaryLoudness],
425    policy: GatingPolicy,
426) -> (Option<f64>, Option<f64>, Option<f64>, Option<f64>, usize) {
427    if policy == GatingPolicy::None {
428        let integrated = mean_energy(blocks.iter().map(|block| block.mean_square));
429        return (None, None, integrated, integrated, blocks.len());
430    }
431    let (absolute, relative) = match policy {
432        GatingPolicy::EbuR128 => (-70.0, -10.0),
433        GatingPolicy::AbsoluteRelative {
434            absolute_lufs,
435            relative_lu,
436        } => (absolute_lufs, relative_lu),
437        GatingPolicy::None => unreachable!(),
438    };
439    let absolute_energies = blocks
440        .iter()
441        .filter(|block| block.lufs.is_some_and(|level| level > absolute))
442        .map(|block| block.mean_square)
443        .collect::<Vec<_>>();
444    let absolute_gated = mean_energy(absolute_energies.iter().copied());
445    let relative_gate = absolute_gated.map(|level| level + relative);
446    let final_energies = blocks
447        .iter()
448        .filter(|block| {
449            block.lufs.is_some_and(|level| {
450                level > absolute && relative_gate.is_none_or(|relative| level > relative)
451            })
452        })
453        .map(|block| block.mean_square)
454        .collect::<Vec<_>>();
455    let gated_blocks = final_energies.len();
456    (
457        Some(absolute),
458        relative_gate,
459        absolute_gated,
460        mean_energy(final_energies.into_iter()),
461        gated_blocks,
462    )
463}
464
465fn channel_weight(channel: LoudnessChannel) -> f64 {
466    match channel {
467        LoudnessChannel::LeftSurround | LoudnessChannel::RightSurround => 1.41,
468        LoudnessChannel::Lfe => 0.0,
469        LoudnessChannel::Center | LoudnessChannel::Left | LoudnessChannel::Right => 1.0,
470    }
471}
472
473fn mean_energy(values: impl Iterator<Item = f64>) -> Option<f64> {
474    let (sum, count) = values.fold((0.0, 0usize), |(sum, count), value| {
475        (sum + value, count + 1)
476    });
477    (count > 0)
478        .then(|| sum / count as f64)
479        .and_then(loudness_level)
480}
481
482fn loudness_level(mean_square: f64) -> Option<f64> {
483    (mean_square > 0.0).then(|| LOUDNESS_OFFSET_DB + 10.0 * mean_square.log10())
484}