Skip to main content

alpha2_audio/
loudness.rs

1//! Loudness measurement: ITU-R BS.1770-4 and EBU R128.
2//!
3//! [`LoudnessMeter`] measures planar audio streamed in blocks of any size:
4//!
5//! - **Momentary** (400 ms) and **short-term** (3 s) loudness, EBU Tech 3341.
6//! - **Loudness range**, EBU Tech 3342: the spread between the 10th and 95th
7//!   percentiles of short-term loudness, gated at -70 LUFS and 20 LU below.
8//! - **True peak** (BS.1770-4 Annex 2): the peak of the signal oversampled
9//!   four times (twice at 96 kHz) through a polyphase interpolator.
10//!
11//! Filtering and sums run in `f64`; logarithms and the filter design use
12//! `libm`, so a measurement is bit-identical natively and as wasm.
13//!
14//! The gated values (integrated loudness, loudness range) come from
15//! histograms of the 400 ms blocks and 3 s windows, 0.01 LU a bin from the
16//! absolute gate to +30 LUFS, each bin keeping its count and its summed
17//! power (libebur128's histogram mode, with sums). A meter takes them when it
18//! is made and never grows, however long it measures, and measuring never
19//! allocates. Means over whole bins are exact; a bin the relative gate
20//! falls in counts or not by the loudness of its own mean, and a percentile
21//! of the range is the loudness of its bin's mean. Against keeping every
22//! value, integrated loudness stays within 0.01 LU and the range within
23//! 0.02 LU (two percentiles); EBU Tech 3341 and 3342 allow 0.1 and 1 LU.
24
25use alpha2_media::{Speaker, SpeakerLayout};
26use libm::{log10, pow, tan};
27
28/// The level below which a block never counts, in LUFS.
29pub const ABSOLUTE_GATE_LUFS: f64 = -70.0;
30/// Integrated loudness ignores blocks this far below the gated level, in LU.
31const INTEGRATED_RELATIVE_GATE: f64 = -10.0;
32/// Loudness range ignores short-term values this far below theirs, in LU.
33const RANGE_RELATIVE_GATE: f64 = -20.0;
34/// Sub-blocks per second: every window is a whole number of 100 ms steps.
35const STEPS_PER_SECOND: u64 = 10;
36/// A momentary window and a gating block: 400 ms.
37const MOMENTARY_STEPS: usize = 4;
38/// A short-term window: 3 s.
39const SHORT_TERM_STEPS: usize = 30;
40/// Interpolator taps per phase in the true-peak oversampler.
41const TRUE_PEAK_TAPS: usize = 12;
42/// Histogram bins per LU.
43const BINS_PER_LU: f64 = 100.0;
44/// Histogram bins: from the absolute gate to +30 LUFS. A louder value
45/// (only far past full scale) counts in the top bin.
46const BINS: usize = 10_000;
47/// A K-weighting filter output under this is silence (as in the `f32`
48/// processors): about -600 dBFS.
49const FLUSH_BELOW: f64 = 1.0e-30;
50
51/// What a [`LoudnessMeter`] has measured.
52#[derive(Copy, Clone, Debug, Default, PartialEq)]
53pub struct LoudnessReport {
54    /// Gated integrated loudness in LUFS; `None` while no block passes the
55    /// absolute gate (silence).
56    pub integrated_lufs: Option<f64>,
57    /// Loudness range in LU; `None` until a 3 s window passes the gates.
58    pub loudness_range_lu: Option<f64>,
59    /// Maximum true peak in dBTP; `None` for digital silence.
60    pub true_peak_dbtp: Option<f64>,
61    /// Maximum sample peak in dBFS; `None` for digital silence.
62    pub sample_peak_dbfs: Option<f64>,
63    /// Loudest momentary (400 ms) value in LUFS.
64    pub momentary_max_lufs: Option<f64>,
65    /// Loudest short-term (3 s) value in LUFS.
66    pub short_term_max_lufs: Option<f64>,
67    /// Samples measured per channel.
68    pub frames: u64,
69}
70
71/// Why a meter cannot be configured or fed.
72#[derive(Copy, Clone, Debug, Eq, PartialEq)]
73#[non_exhaustive]
74pub enum LoudnessError {
75    /// No channels, a rate outside 8 to 96 kHz, or a non-finite weight.
76    InvalidShape,
77    /// A block's channel count or plane lengths do not match.
78    MismatchedBlock,
79}
80
81impl std::fmt::Display for LoudnessError {
82    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83        formatter.write_str(match self {
84            Self::InvalidShape => "a loudness meter needs channels and a rate of 8 to 96 kHz",
85            Self::MismatchedBlock => "the block's channels do not match the meter's",
86        })
87    }
88}
89
90impl std::error::Error for LoudnessError {}
91
92/// One channel's K-weighting filters and true-peak history.
93#[derive(Clone, Debug)]
94struct Channel {
95    weight: f64,
96    shelf: Biquad64,
97    high_pass: Biquad64,
98    /// The last `TRUE_PEAK_TAPS` input samples, newest last.
99    history: [f64; TRUE_PEAK_TAPS],
100}
101
102/// A direct-form-I biquad in `f64` with the meter's own fixed coefficients.
103#[derive(Copy, Clone, Debug)]
104struct Biquad64 {
105    b: [f64; 3],
106    a: [f64; 2],
107    x: [f64; 2],
108    y: [f64; 2],
109}
110
111impl Biquad64 {
112    const fn new(b: [f64; 3], a: [f64; 2]) -> Self {
113        Self {
114            b,
115            a,
116            x: [0.0; 2],
117            y: [0.0; 2],
118        }
119    }
120
121    fn process(&mut self, input: f64) -> f64 {
122        let output = self.b[0] * input + self.b[1] * self.x[0] + self.b[2] * self.x[1]
123            - self.a[0] * self.y[0]
124            - self.a[1] * self.y[1];
125        // In digital silence the recursion would settle in the slow subnormal range; this is
126        // hundreds of LU under the absolute gate, so no gated value moves.
127        let output = if output.abs() < FLUSH_BELOW {
128            0.0
129        } else {
130            output
131        };
132        self.x = [input, self.x[0]];
133        self.y = [output, self.y[0]];
134        output
135    }
136}
137
138/// The BS.1770-4 pre-filter (a high shelf of about +4 dB above 1.5 kHz) for
139/// `sample_rate`.
140fn shelf(sample_rate: u32) -> Biquad64 {
141    let f0 = 1_681.974_450_955_533;
142    let gain_db = 3.999_843_853_973_347;
143    let q = 0.707_175_236_955_419_6;
144    let k = tan(core::f64::consts::PI * f0 / f64::from(sample_rate));
145    let vh = pow(10.0, gain_db / 20.0);
146    let vb = pow(vh, 0.499_666_774_154_541_6);
147    let a0 = 1.0 + k / q + k * k;
148    Biquad64::new(
149        [
150            (vh + vb * k / q + k * k) / a0,
151            2.0 * (k * k - vh) / a0,
152            (vh - vb * k / q + k * k) / a0,
153        ],
154        [2.0 * (k * k - 1.0) / a0, (1.0 - k / q + k * k) / a0],
155    )
156}
157
158/// The BS.1770-4 RLB weighting (a second-order high-pass at about 38 Hz) for
159/// `sample_rate`.
160fn high_pass(sample_rate: u32) -> Biquad64 {
161    let f0 = 38.135_470_876_024_44;
162    let q = 0.500_327_037_323_877_3;
163    let k = tan(core::f64::consts::PI * f0 / f64::from(sample_rate));
164    let a0 = 1.0 + k / q + k * k;
165    Biquad64::new(
166        [1.0, -2.0, 1.0],
167        [2.0 * (k * k - 1.0) / a0, (1.0 - k / q + k * k) / a0],
168    )
169}
170
171/// The loudness of a mean weighted power, in LUFS (BS.1770-4 eq. 2).
172fn lufs(power: f64) -> f64 {
173    -0.691 + 10.0 * log10(power)
174}
175
176/// An amplitude's level in decibels relative to full scale.
177fn decibels(amplitude: f64) -> f64 {
178    20.0 * log10(amplitude)
179}
180
181/// One 100 ms step: its weighted sum of squares and its sample count.
182#[derive(Copy, Clone, Debug, Default)]
183struct Step {
184    energy: f64,
185    samples: u64,
186}
187
188/// One histogram bin: how many values fell in it and their summed power.
189#[derive(Copy, Clone, Debug, Default)]
190struct Bin {
191    count: u64,
192    power: f64,
193}
194
195/// The powers over the absolute gate, binned by loudness (see the module
196/// documentation), with their count and sum in arrival order.
197#[derive(Clone, Debug)]
198struct Histogram {
199    bins: Box<[Bin]>,
200    count: u64,
201    power: f64,
202}
203
204impl Histogram {
205    fn new() -> Self {
206        Self {
207            bins: vec![Bin::default(); BINS].into_boxed_slice(),
208            count: 0,
209            power: 0.0,
210        }
211    }
212
213    /// Counts `power` if it is over the absolute gate. A power that is not a
214    /// finite positive number (no meter fed finite samples makes one) is left
215    /// out.
216    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
217    fn add(&mut self, power: f64) {
218        if !(power.is_finite() && power > 0.0) {
219            return;
220        }
221        let loudness = lufs(power);
222        if loudness <= ABSOLUTE_GATE_LUFS {
223            return;
224        }
225        let index = (((loudness - ABSOLUTE_GATE_LUFS) * BINS_PER_LU) as usize).min(BINS - 1);
226        let bin = &mut self.bins[index];
227        bin.count += 1;
228        bin.power += power;
229        self.count += 1;
230        self.power += power;
231    }
232
233    /// The loudness `relative` LU below the mean of everything counted, but
234    /// never under the absolute gate (BS.1770-4 eq. 7 counts only the values
235    /// over both gates); `None` while nothing is counted.
236    #[allow(clippy::cast_precision_loss)]
237    fn relative_gate(&self, relative: f64) -> Option<f64> {
238        (self.count > 0)
239            .then(|| (lufs(self.power / self.count as f64) + relative).max(ABSOLUTE_GATE_LUFS))
240    }
241
242    /// The bins over `threshold`, quietest first: a bin counts whole when
243    /// the loudness of its mean is over it.
244    #[allow(clippy::cast_precision_loss)]
245    fn over(&self, threshold: f64) -> impl Iterator<Item = &Bin> {
246        self.bins
247            .iter()
248            .filter(move |bin| bin.count > 0 && lufs(bin.power / bin.count as f64) > threshold)
249    }
250
251    /// BS.1770-4 eq. 7: the mean power of the values over the absolute gate
252    /// and over the relative gate `relative` LU below their own mean.
253    #[allow(clippy::cast_precision_loss)]
254    fn gated_mean(&self, relative: f64) -> Option<f64> {
255        let threshold = self.relative_gate(relative)?;
256        let (power, count) = self
257            .over(threshold)
258            .fold((0.0, 0_u64), |(power, count), bin| {
259                (power + bin.power, count + bin.count)
260            });
261        (count > 0).then(|| power / count as f64)
262    }
263
264    /// EBU Tech 3342: the 95th minus the 10th percentile (nearest rank) of
265    /// the loudness over the absolute gate and the relative gate `relative`
266    /// LU below, each the loudness of its bin's mean.
267    #[allow(
268        clippy::cast_possible_truncation,
269        clippy::cast_precision_loss,
270        clippy::cast_sign_loss
271    )]
272    fn range(&self, relative: f64) -> Option<f64> {
273        let threshold = self.relative_gate(relative)?;
274        let count = self.over(threshold).map(|bin| bin.count).sum::<u64>();
275        let last = count.checked_sub(1)? as f64;
276        let at = |percentile: f64| {
277            let rank = (last * percentile + 0.5) as u64;
278            let mut below = 0;
279            self.over(threshold)
280                .find(|bin| {
281                    below += bin.count;
282                    below > rank
283                })
284                .map(|bin| lufs(bin.power / bin.count as f64))
285        };
286        Some(at(0.95)? - at(0.10)?)
287    }
288}
289
290/// A streaming BS.1770-4 / EBU R128 loudness meter.
291#[derive(Clone, Debug)]
292pub struct LoudnessMeter {
293    sample_rate: u32,
294    channels: Vec<Channel>,
295    /// Polyphase true-peak interpolator: `phases[p][tap]`.
296    phases: Vec<[f64; TRUE_PEAK_TAPS]>,
297    open: Step,
298    /// Index of the step being filled.
299    step_index: u64,
300    /// The most recent steps, newest last, at most a short-term window.
301    recent: Vec<Step>,
302    /// The mean powers of the complete 400 ms gating blocks.
303    blocks: Histogram,
304    /// The mean powers of the complete 3 s short-term windows.
305    short_terms: Histogram,
306    momentary_max: Option<f64>,
307    short_term_max: Option<f64>,
308    true_peak: f64,
309    sample_peak: f64,
310    frames: u64,
311}
312
313impl LoudnessMeter {
314    /// A meter for `channels` channels at `sample_rate`, in the layout a
315    /// source of that many channels that declares none has
316    /// ([`SpeakerLayout::default_for`]: `FFmpeg`'s default order, which the
317    /// mix folds such a source by), weighted as [`Self::for_speakers`]
318    /// weighs it.
319    ///
320    /// | Channels | Order | Weights |
321    /// |---|---|---|
322    /// | 1, 2 | C / L R | 1 |
323    /// | 3 | L R LFE | 1 1 0 |
324    /// | 4 | L R C Cs | 1 1 1 1 |
325    /// | 5 | L R C Ls Rs | 1 1 1 1.41 1.41 |
326    /// | 6 | L R C LFE Ls Rs | 1 1 1 0 1.41 1.41 |
327    /// | 7 | L R C LFE Cs Ls Rs | 1 1 1 0 1 1.41 1.41 |
328    /// | 8 | L R C LFE Lrs Rrs Lss Rss | 1 1 1 0 1 1 1.41 1.41 |
329    ///
330    /// Above eight, the named layouts (5.1.4, 7.1.4, 9.1.4, 9.1.6, 22.2)
331    /// weigh by their speakers too, and a count with none is a 7.1 whose
332    /// further channels weigh 1.0.
333    ///
334    /// # Errors
335    /// Returns [`LoudnessError::InvalidShape`] for zero channels, more than
336    /// [`alpha2_media::MAX_AUDIO_CHANNELS`], or a rate outside 8 to 96 kHz.
337    pub fn new(sample_rate: u32, channels: usize) -> Result<Self, LoudnessError> {
338        let layout = SpeakerLayout::default_for(channels).ok_or(LoudnessError::InvalidShape)?;
339        Self::for_speakers(sample_rate, layout.speakers())
340    }
341
342    /// A meter for planes feeding `speakers`, weighted as BS.1770-4 (Annex 1,
343    /// Table 4) weighs them: the LFE does not count, a surround 60 to 120
344    /// degrees off centre at ear height weighs 1.41, and every other
345    /// speaker 1.0.
346    ///
347    /// The side speakers sit there (90 to 110 degrees), and so do the back
348    /// pair of a layout without sides: the surrounds of 5.0 and 5.1, which
349    /// `FFmpeg` names back left and right. Beside sides, the back pair is a
350    /// 7.1's rear (135 to 150 degrees) and weighs 1.0. A height or bottom
351    /// speaker is 30 degrees off ear height and weighs 1.0, as does a
352    /// speaker no position names.
353    ///
354    /// # Errors
355    /// Returns [`LoudnessError::InvalidShape`] for no speakers or a rate
356    /// outside 8 to 96 kHz.
357    pub fn for_speakers(sample_rate: u32, speakers: &[Speaker]) -> Result<Self, LoudnessError> {
358        const SURROUND: f64 = 1.41;
359        let has_sides = speakers
360            .iter()
361            .any(|speaker| matches!(speaker, Speaker::SideLeft | Speaker::SideRight));
362        let weights = speakers
363            .iter()
364            .map(|speaker| match speaker {
365                Speaker::LowFrequency | Speaker::LowFrequency2 => 0.0,
366                Speaker::SideLeft
367                | Speaker::SideRight
368                | Speaker::SideSurroundLeft
369                | Speaker::SideSurroundRight
370                | Speaker::SurroundDirectLeft
371                | Speaker::SurroundDirectRight
372                | Speaker::WideLeft
373                | Speaker::WideRight => SURROUND,
374                Speaker::BackLeft | Speaker::BackRight if !has_sides => SURROUND,
375                _ => 1.0,
376            })
377            .collect::<Vec<_>>();
378        Self::with_weights(sample_rate, &weights)
379    }
380
381    /// A meter with one explicit weight per channel.
382    ///
383    /// # Errors
384    /// Returns [`LoudnessError::InvalidShape`] for no weights, a negative or
385    /// non-finite weight, or a rate outside 8 to 96 kHz.
386    pub fn with_weights(sample_rate: u32, weights: &[f64]) -> Result<Self, LoudnessError> {
387        if weights.is_empty()
388            || weights
389                .iter()
390                .any(|weight| !weight.is_finite() || *weight < 0.0)
391            || !(8_000..=96_000).contains(&sample_rate)
392        {
393            return Err(LoudnessError::InvalidShape);
394        }
395        let factor = if sample_rate < 96_000 { 4 } else { 2 };
396        Ok(Self {
397            sample_rate,
398            channels: weights
399                .iter()
400                .map(|&weight| Channel {
401                    weight,
402                    shelf: shelf(sample_rate),
403                    high_pass: high_pass(sample_rate),
404                    history: [0.0; TRUE_PEAK_TAPS],
405                })
406                .collect(),
407            phases: interpolator(factor),
408            open: Step::default(),
409            step_index: 0,
410            recent: Vec::with_capacity(SHORT_TERM_STEPS),
411            blocks: Histogram::new(),
412            short_terms: Histogram::new(),
413            momentary_max: None,
414            short_term_max: None,
415            true_peak: 0.0,
416            sample_peak: 0.0,
417            frames: 0,
418        })
419    }
420
421    /// Measures one block of planar samples, one slice per channel, all of
422    /// one length. An infinite or NaN sample measures as silence.
423    ///
424    /// # Errors
425    /// Returns [`LoudnessError::MismatchedBlock`] for another channel count
426    /// or ragged planes.
427    pub fn push(&mut self, planes: &[&[f32]]) -> Result<(), LoudnessError> {
428        let Some(frames) = planes.first().map(|plane| plane.len()) else {
429            return Err(LoudnessError::MismatchedBlock);
430        };
431        if planes.len() != self.channels.len() || planes.iter().any(|plane| plane.len() != frames) {
432            return Err(LoudnessError::MismatchedBlock);
433        }
434        for index in 0..frames {
435            let mut energy = 0.0;
436            for (channel, plane) in self.channels.iter_mut().zip(planes) {
437                // A NaN measures as silence, as the limiter after the meter plays it: the recursive
438                // filters would carry it to the end, and an infinite power has no loudness.
439                let sample = plane[index];
440                let sample = if sample.is_finite() {
441                    f64::from(sample)
442                } else {
443                    0.0
444                };
445                let weighted = channel.high_pass.process(channel.shelf.process(sample));
446                energy += channel.weight * weighted * weighted;
447                self.sample_peak = self.sample_peak.max(sample.abs());
448                channel.history.copy_within(1.., 0);
449                channel.history[TRUE_PEAK_TAPS - 1] = sample;
450                for phase in &self.phases {
451                    let interpolated = phase
452                        .iter()
453                        .zip(channel.history.iter().rev())
454                        .fold(0.0, |sum, (tap, value)| sum + tap * value);
455                    self.true_peak = self.true_peak.max(interpolated.abs());
456                }
457            }
458            self.open.energy += energy;
459            self.open.samples += 1;
460            self.frames += 1;
461            if self.frames == self.step_end() {
462                self.close_step();
463            }
464        }
465        Ok(())
466    }
467
468    /// The sample after the step being filled: steps split each second
469    /// into ten, `floor(k * rate / 10)`, so they stay exact at 11 025 Hz.
470    fn step_end(&self) -> u64 {
471        (self.step_index + 1) * u64::from(self.sample_rate) / STEPS_PER_SECOND
472    }
473
474    fn close_step(&mut self) {
475        if self.recent.len() == SHORT_TERM_STEPS {
476            self.recent.remove(0);
477        }
478        self.recent.push(self.open);
479        self.open = Step::default();
480        self.step_index += 1;
481        if let Some(power) = self.window_power(MOMENTARY_STEPS) {
482            self.blocks.add(power);
483            let loudness = lufs(power);
484            self.momentary_max = Some(self.momentary_max.map_or(loudness, |max| max.max(loudness)));
485        }
486        if let Some(power) = self.window_power(SHORT_TERM_STEPS) {
487            self.short_terms.add(power);
488            let loudness = lufs(power);
489            self.short_term_max = Some(
490                self.short_term_max
491                    .map_or(loudness, |max| max.max(loudness)),
492            );
493        }
494    }
495
496    /// The mean power of the last `steps` complete steps, once there are
497    /// that many.
498    #[allow(clippy::cast_precision_loss)]
499    fn window_power(&self, steps: usize) -> Option<f64> {
500        let window = self.recent.get(self.recent.len().checked_sub(steps)?..)?;
501        let (energy, samples) = window.iter().fold((0.0, 0_u64), |(energy, samples), step| {
502            (energy + step.energy, samples + step.samples)
503        });
504        (samples > 0).then(|| energy / samples as f64)
505    }
506
507    /// Momentary loudness of the last 400 ms, in LUFS.
508    #[must_use]
509    pub fn momentary_lufs(&self) -> Option<f64> {
510        self.window_power(MOMENTARY_STEPS).map(lufs)
511    }
512
513    /// Short-term loudness of the last 3 s, in LUFS.
514    #[must_use]
515    pub fn short_term_lufs(&self) -> Option<f64> {
516        self.window_power(SHORT_TERM_STEPS).map(lufs)
517    }
518
519    /// Everything measured so far.
520    #[must_use]
521    pub fn report(&self) -> LoudnessReport {
522        let peak = |amplitude: f64| (amplitude > 0.0).then(|| decibels(amplitude));
523        LoudnessReport {
524            integrated_lufs: self.blocks.gated_mean(INTEGRATED_RELATIVE_GATE).map(lufs),
525            loudness_range_lu: self.short_terms.range(RANGE_RELATIVE_GATE),
526            true_peak_dbtp: peak(self.true_peak.max(self.sample_peak)),
527            sample_peak_dbfs: peak(self.sample_peak),
528            momentary_max_lufs: self.momentary_max,
529            short_term_max_lufs: self.short_term_max,
530            frames: self.frames,
531        }
532    }
533}
534
535/// The polyphase interpolator of the true-peak oversampler: a low-pass at the
536/// original Nyquist frequency, Hann-windowed sinc, `factor` phases of
537/// `TRUE_PEAK_TAPS` taps, each phase normalized to unity gain.
538#[allow(clippy::cast_precision_loss)]
539fn interpolator(factor: usize) -> Vec<[f64; TRUE_PEAK_TAPS]> {
540    let length = factor * TRUE_PEAK_TAPS;
541    let centre = (length as f64 - 1.0) / 2.0;
542    let prototype = (0..length)
543        .map(|index| {
544            let offset = (index as f64 - centre) / factor as f64;
545            let sinc = if offset.abs() < 1e-12 {
546                1.0
547            } else {
548                let angle = core::f64::consts::PI * offset;
549                libm::sin(angle) / angle
550            };
551            let window = 0.5
552                - 0.5
553                    * libm::cos(2.0 * core::f64::consts::PI * (index as f64 + 0.5) / length as f64);
554            sinc * window
555        })
556        .collect::<Vec<_>>();
557    (0..factor)
558        .map(|phase| {
559            let mut taps = [0.0; TRUE_PEAK_TAPS];
560            for (tap, value) in taps.iter_mut().enumerate() {
561                *value = prototype[phase + tap * factor];
562            }
563            let sum = taps.iter().sum::<f64>();
564            taps.map(|value| value / sum)
565        })
566        .collect()
567}
568
569/// How far below a master limiter's threshold a normalized mix keeps its
570/// sample peak, in dB, so the limiter (which acts only on a sample above its
571/// threshold) leaves it alone. It covers the rounding of the 32-bit gain and
572/// of each sample multiplied by it (a few parts in 10^7, about 2e-6 dB) a
573/// thousand times over, and is far below an audible level change.
574pub const LIMITER_MARGIN_DB: f64 = 0.01;
575
576/// A static gain that brings a measured mix to a loudness target without
577/// letting its true peak exceed a ceiling, or its sample peak reach a master
578/// limiter after it.
579#[derive(Copy, Clone, Debug, PartialEq)]
580pub struct LoudnessNormalization {
581    /// Gain to apply, in dB.
582    pub gain_db: f64,
583    /// Integrated loudness after the gain, in LUFS.
584    pub integrated_lufs: f64,
585    /// True peak after the gain, in dBTP.
586    pub true_peak_dbtp: f64,
587    /// Whether a peak bound, not the target, set the gain: the true-peak
588    /// ceiling or the limiter ([`Self::limiter_limited`]). The result is
589    /// quieter than the target.
590    pub peak_limited: bool,
591    /// Whether the master limiter's threshold was the bound that set the
592    /// gain, below what the ceiling allows.
593    pub limiter_limited: bool,
594}
595
596impl LoudnessNormalization {
597    /// The gain that moves `report` to `target_lufs`, lowered if needed so
598    /// the true peak stays at or below `ceiling_dbtp` and, when the mix goes
599    /// on through a master limiter whose linear threshold is
600    /// `limiter_threshold`, so the sample peak stays [`LIMITER_MARGIN_DB`]
601    /// below that threshold. The limiter then never acts on the normalized
602    /// mix, which comes out at the planned loudness. The limiter bound is on
603    /// the sample peak because that is what the limiter detects.
604    ///
605    /// `None` when the measured audio has no integrated loudness (it is
606    /// silent below the absolute gate), or when the report holds a loudness
607    /// or peak, or the threshold is, not a finite (and for the threshold,
608    /// positive) number, which no gain can be planned from.
609    #[must_use]
610    pub fn plan(
611        report: &LoudnessReport,
612        target_lufs: f64,
613        ceiling_dbtp: f64,
614        limiter_threshold: Option<f64>,
615    ) -> Option<Self> {
616        let integrated = report.integrated_lufs.filter(|value| value.is_finite())?;
617        if report.true_peak_dbtp.is_some_and(|peak| !peak.is_finite())
618            || report
619                .sample_peak_dbfs
620                .is_some_and(|peak| !peak.is_finite())
621            || limiter_threshold
622                .is_some_and(|threshold| !(threshold.is_finite() && threshold > 0.0))
623        {
624            return None;
625        }
626        let to_target = target_lufs - integrated;
627        let to_ceiling = report
628            .true_peak_dbtp
629            .map_or(f64::INFINITY, |peak| ceiling_dbtp - peak);
630        let to_limiter = match (limiter_threshold, report.sample_peak_dbfs) {
631            (Some(threshold), Some(peak)) => {
632                20.0 * libm::log10(threshold) - LIMITER_MARGIN_DB - peak
633            }
634            _ => f64::INFINITY,
635        };
636        let gain_db = to_target.min(to_ceiling).min(to_limiter);
637        Some(Self {
638            gain_db,
639            integrated_lufs: integrated + gain_db,
640            true_peak_dbtp: report
641                .true_peak_dbtp
642                .map_or(f64::NEG_INFINITY, |peak| peak + gain_db),
643            peak_limited: gain_db < to_target,
644            limiter_limited: to_limiter < to_target.min(to_ceiling),
645        })
646    }
647
648    /// The gain as a linear factor.
649    #[must_use]
650    pub fn linear_gain(&self) -> f64 {
651        pow(10.0, self.gain_db / 20.0)
652    }
653}
654
655#[cfg(test)]
656#[path = "loudness_tests.rs"]
657mod tests;