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