Skip to main content

ebur128_stream/
normalize.rs

1//! Loudness normalisation helpers.
2//!
3//! Two pieces:
4//!
5//! 1. [`gain_to_target`] — pure function: given a measured [`Report`]
6//!    and a target LUFS, return the linear gain to apply.
7//! 2. [`Normalizer`] — convenience pipeline: run a buffer through the
8//!    analyzer, apply the calibrated gain, optionally clip-protect
9//!    against true-peak overshoot.
10//!
11//! The normaliser is intentionally a *separate* concern from the
12//! analyzer (per the SPEC discipline) but lives in the same crate so
13//! callers don't have to wire the math themselves. This module is
14//! gated behind the `normalize` feature.
15//!
16//! # Example
17//!
18//! ```
19//! use ebur128_stream::{normalize::Normalizer, Channel, Mode};
20//!
21//! // 1 second of -3 dBFS sine (≈ -3 LUFS-ish)
22//! let mut buf: Vec<f32> = (0..48_000)
23//!     .flat_map(|i| {
24//!         let v = 0.7 * (2.0 * std::f32::consts::PI * 1000.0 * i as f32 / 48_000.0).sin();
25//!         [v, v]
26//!     })
27//!     .collect();
28//!
29//! let n = Normalizer::new(48_000, &[Channel::Left, Channel::Right])
30//!     .target_lufs(-23.0)
31//!     .true_peak_ceiling_dbtp(-1.0);
32//! let report = n.normalize_in_place(&mut buf)?;
33//! assert!(report.applied_gain_db.is_finite());
34//! # Ok::<(), ebur128_stream::Error>(())
35//! ```
36
37use crate::analyzer::AnalyzerBuilder;
38use crate::channel::Channel;
39use crate::error::Error;
40use crate::mode::Mode;
41use crate::report::Report;
42
43/// Compute the linear amplitude gain that, when applied to the audio
44/// the [`Report`] was measured from, would shift its integrated
45/// loudness from `report.integrated_lufs()` to `target_lufs`.
46///
47/// Returns `None` if the report has no integrated value (silent
48/// programme).
49///
50/// # Example
51///
52/// ```
53/// # use ebur128_stream::{AnalyzerBuilder, Channel, Mode, normalize};
54/// let mut a = AnalyzerBuilder::new()
55///     .sample_rate(48_000)
56///     .channels(&[Channel::Center])
57///     .modes(Mode::Integrated)
58///     .build()?;
59/// let signal: Vec<f32> = (0..48_000)
60///     .map(|i| 0.1 * (2.0 * std::f32::consts::PI * 1000.0 * i as f32 / 48_000.0).sin())
61///     .collect();
62/// a.push_interleaved::<f32>(&signal)?;
63/// let report = a.finalize();
64///
65/// let gain = normalize::gain_to_target(&report, -23.0).unwrap();
66/// // gain is a linear multiplier; applying it would raise integrated
67/// // LUFS to ≈ -23.0.
68/// assert!(gain.is_finite() && gain > 0.0);
69/// # Ok::<(), ebur128_stream::Error>(())
70/// ```
71#[must_use]
72pub fn gain_to_target(report: &Report, target_lufs: f64) -> Option<f64> {
73    let i = report.integrated_lufs()?;
74    let delta_db = target_lufs - i;
75    Some(libm::pow(10.0, delta_db / 20.0))
76}
77
78/// Result of a normalisation pass.
79#[derive(Debug, Clone, Copy, PartialEq)]
80pub struct NormalizeReport {
81    /// The measured integrated loudness *before* normalisation.
82    pub measured_integrated_lufs: Option<f64>,
83    /// The measured true peak *before* normalisation, in dBTP.
84    pub measured_true_peak_dbtp: Option<f64>,
85    /// The target loudness, in LUFS.
86    pub target_lufs: f64,
87    /// The true-peak ceiling in dBTP, or `None` if no ceiling was set.
88    pub true_peak_ceiling_dbtp: Option<f64>,
89    /// The gain that was actually applied, in dB.
90    ///
91    /// May differ from `target_lufs - measured_integrated_lufs` if
92    /// the true-peak ceiling reduced it.
93    pub applied_gain_db: f64,
94    /// `true` if the gain was attenuated to honour the true-peak
95    /// ceiling.
96    pub limited_by_true_peak: bool,
97}
98
99/// One-shot normaliser: analyse + scale a buffer in place.
100#[derive(Debug, Clone)]
101pub struct Normalizer<'a> {
102    sample_rate: u32,
103    channels: &'a [Channel],
104    target_lufs: f64,
105    true_peak_ceiling_dbtp: Option<f64>,
106}
107
108impl<'a> Normalizer<'a> {
109    /// Build a fresh normaliser. Default target is −23 LUFS (EBU R128
110    /// broadcast reference).
111    #[must_use]
112    pub fn new(sample_rate: u32, channels: &'a [Channel]) -> Self {
113        Self {
114            sample_rate,
115            channels,
116            target_lufs: -23.0,
117            true_peak_ceiling_dbtp: None,
118        }
119    }
120
121    /// Set the target integrated loudness in LUFS.
122    #[must_use]
123    pub fn target_lufs(mut self, lufs: f64) -> Self {
124        self.target_lufs = lufs;
125        self
126    }
127
128    /// Cap the post-normalisation true peak at this dBTP value. If the
129    /// gain required to reach `target_lufs` would push the true peak
130    /// above the ceiling, the gain is reduced.
131    #[must_use]
132    pub fn true_peak_ceiling_dbtp(mut self, dbtp: f64) -> Self {
133        self.true_peak_ceiling_dbtp = Some(dbtp);
134        self
135    }
136
137    /// Run the analyser, compute the gain, and scale `samples` in place.
138    /// `samples` is interleaved (`[L0 R0 L1 R1 ...]`).
139    ///
140    /// Returns the [`NormalizeReport`] describing what happened.
141    ///
142    /// # Errors
143    ///
144    /// Anything [`AnalyzerBuilder::build`] or
145    /// [`crate::Analyzer::push_interleaved`] can return.
146    pub fn normalize_in_place(&self, samples: &mut [f32]) -> Result<NormalizeReport, Error> {
147        let mut a = AnalyzerBuilder::new()
148            .sample_rate(self.sample_rate)
149            .channels(self.channels)
150            .modes(Mode::Integrated | Mode::TruePeak)
151            .build()?;
152        a.push_interleaved::<f32>(samples)?;
153        let report = a.finalize();
154
155        let measured_i = report.integrated_lufs();
156        let measured_tp = report.true_peak_dbtp();
157
158        let mut gain_db = match measured_i {
159            Some(i) => self.target_lufs - i,
160            None => 0.0,
161        };
162        let mut limited_by_true_peak = false;
163        if let (Some(ceiling), Some(tp)) = (self.true_peak_ceiling_dbtp, measured_tp) {
164            // After applying gain_db, the new TP would be tp + gain_db.
165            // Cap so tp + gain_db <= ceiling.
166            let max_gain = ceiling - tp;
167            if gain_db > max_gain {
168                gain_db = max_gain;
169                limited_by_true_peak = true;
170            }
171        }
172        let gain_lin = libm::pow(10.0, gain_db / 20.0) as f32;
173        for s in samples.iter_mut() {
174            *s *= gain_lin;
175        }
176
177        Ok(NormalizeReport {
178            measured_integrated_lufs: measured_i,
179            measured_true_peak_dbtp: measured_tp,
180            target_lufs: self.target_lufs,
181            true_peak_ceiling_dbtp: self.true_peak_ceiling_dbtp,
182            applied_gain_db: gain_db,
183            limited_by_true_peak,
184        })
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191    use crate::AnalyzerBuilder;
192
193    fn synth_stereo(amp: f32, secs: f32, fs: u32) -> Vec<f32> {
194        let n = (fs as f32 * secs) as usize;
195        let omega = 2.0 * std::f32::consts::PI * 1000.0 / fs as f32;
196        let mut v = Vec::with_capacity(n * 2);
197        for i in 0..n {
198            let s = amp * (omega * i as f32).sin();
199            v.push(s);
200            v.push(s);
201        }
202        v
203    }
204
205    #[test]
206    fn gain_to_target_zero_for_self() {
207        // Build a -23 LUFS programme; gain to reach -23 should ≈ 1.0.
208        let mut a = AnalyzerBuilder::new()
209            .sample_rate(48_000)
210            .channels(&[Channel::Left, Channel::Right])
211            .modes(Mode::Integrated)
212            .build()
213            .unwrap();
214        let s = synth_stereo(0.05, 5.0, 48_000); // ≈ -29 LUFS
215        a.push_interleaved::<f32>(&s).unwrap();
216        let r = a.finalize();
217        let measured = r.integrated_lufs().unwrap();
218        let g = gain_to_target(&r, measured).unwrap();
219        assert!((g - 1.0).abs() < 1e-9, "gain to self = {g}");
220    }
221
222    #[test]
223    fn normalise_brings_loudness_to_target() {
224        let mut signal = synth_stereo(0.05, 5.0, 48_000);
225        let n = Normalizer::new(48_000, &[Channel::Left, Channel::Right]).target_lufs(-23.0);
226        let r = n.normalize_in_place(&mut signal).unwrap();
227
228        // Re-measure the normalised buffer; should be near -23 LUFS.
229        let mut a = AnalyzerBuilder::new()
230            .sample_rate(48_000)
231            .channels(&[Channel::Left, Channel::Right])
232            .modes(Mode::Integrated)
233            .build()
234            .unwrap();
235        a.push_interleaved::<f32>(&signal).unwrap();
236        let after = a.finalize().integrated_lufs().unwrap();
237        assert!(
238            (after - (-23.0)).abs() <= 0.1,
239            "after-norm I = {after}, expected ≈ -23"
240        );
241        assert!(r.applied_gain_db > 0.0);
242    }
243
244    #[test]
245    fn true_peak_ceiling_attenuates_gain() {
246        // Aim for a *loud* target with a strict TP ceiling — gain to
247        // reach the target would push TP above the ceiling, so the
248        // gain is capped.
249        let mut signal = synth_stereo(0.05, 5.0, 48_000);
250        let n = Normalizer::new(48_000, &[Channel::Left, Channel::Right])
251            .target_lufs(-1.0) // very loud
252            .true_peak_ceiling_dbtp(-1.0);
253        let r = n.normalize_in_place(&mut signal).unwrap();
254        assert!(
255            r.limited_by_true_peak,
256            "expected the gain to be limited by the TP ceiling"
257        );
258        // After applying capped gain, true peak should be ≤ -1 dBTP + ε.
259        let mut a = AnalyzerBuilder::new()
260            .sample_rate(48_000)
261            .channels(&[Channel::Left, Channel::Right])
262            .modes(Mode::TruePeak)
263            .build()
264            .unwrap();
265        a.push_interleaved::<f32>(&signal).unwrap();
266        let tp = a.finalize().true_peak_dbtp().unwrap();
267        assert!(tp <= -1.0 + 0.5, "tp after cap = {tp}, expected ≤ -1 + ε");
268    }
269}