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}