Skip to main content

auracle_features/
loudness.rs

1//! ITU-R BS.1770-style loudness measurement and normalization (mono).
2//!
3//! Why LUFS and not plain RMS: preference data is poisoned by loudness —
4//! louder reliably wins A/B tests — so candidates must be matched on
5//! *perceived* loudness before audition and feature extraction. K-weighting
6//! (a high-shelf boost above ~1.7 kHz plus a ~38 Hz highpass) approximates
7//! the ear's sensitivity, and 400 ms gated blocks keep silence and release
8//! tails from dragging the measurement down.
9//!
10//! The filter coefficients are derived parametrically (RBJ bilinear
11//! transform) from the BS.1770 analog prototype — the same approach
12//! pyloudnorm uses — so any sample rate works, matching the spec's published
13//! 48 kHz coefficients at 48 kHz.
14//!
15//! ## Loudness is a target, not a promise: the peak wins
16//!
17//! Matching integrated loudness says nothing about the peak. Crest factor
18//! varies by tens of dB across this grammar — a pad and a pluck at the same
19//! LUFS are nowhere near the same peak — so normalizing to a target level
20//! sends percussive patches well over full scale. Measured over 150 vetted
21//! prior draws before [`PEAK_CEILING`] existed: **15 % of renders peaked above
22//! 1.0 and 8 % above 1.25** (which is where the app's `master.gain = 0.8`
23//! clips), with a worst case of **4.06** — 12 dB over.
24//!
25//! That is not a cosmetic defect. Preference data is elicited on this exact
26//! buffer, so a clipped audition collects a vote about *clipping* rather than
27//! about the patch — which is precisely the confound loudness normalization
28//! exists to remove, one stage later and silent. The live voice was never
29//! exposed to it (`auracle_wasm::live`'s master limiter has always held a 0.98
30//! ceiling); the offline path took the volt divisor and not the limiter.
31//!
32//! **The fix is a smaller gain, not a limiter.** [`normalize_to`] gives up
33//! whatever makeup it has to for the peak to clear [`PEAK_CEILING`], and
34//! reports how much in [`NormReport::peak_reduction_db`]. A limiter would hold
35//! the loudness target but reshape the waveform, which moves `crest`,
36//! `flatness_mean` and `flux_mean` as well as the RMS pair, and would need a
37//! second copy of itself inside
38//! [`render_playback`](crate::render::render_playback) kept in lockstep
39//! forever. A scalar keeps that replay bit-identical **by construction** and
40//! cannot change timbre at all.
41//!
42//! What it costs, stated plainly: the ~15 % of patches that hit the ceiling
43//! sit *below* the loudness target, so they audition quieter than the rest.
44//! Loudness matching degrades exactly where crest is highest. That is the
45//! right trade — quieter is a smaller bias on a preference judgment than
46//! clipped — but it is a trade, and `peak_reduction_db` is on the record so a
47//! surface can say "pulled down 3.2 dB so it would not clip" instead of
48//! pretending the patch was simply quiet.
49
50/// A biquad in direct form 1.
51#[derive(Clone, Copy, Debug)]
52struct Biquad {
53    b0: f64,
54    b1: f64,
55    b2: f64,
56    a1: f64,
57    a2: f64,
58}
59
60impl Biquad {
61    fn process(self, x: &mut [f64]) {
62        let (mut x1, mut x2, mut y1, mut y2) = (0.0, 0.0, 0.0, 0.0);
63        for s in x.iter_mut() {
64            let x0 = *s;
65            let y0 = self.b0 * x0 + self.b1 * x1 + self.b2 * x2 - self.a1 * y1 - self.a2 * y2;
66            x2 = x1;
67            x1 = x0;
68            y2 = y1;
69            y1 = y0;
70            *s = y0;
71        }
72    }
73}
74
75/// BS.1770 stage 1: high-shelf (+3.99984 dB above ~1681.97 Hz, Q 0.70718).
76fn k_shelf(fs: f64) -> Biquad {
77    let (g_db, q, fc) = (
78        3.999_843_853_973_347,
79        0.707_175_236_955_419_6,
80        1_681.974_450_955_533,
81    );
82    let k = (std::f64::consts::PI * fc / fs).tan();
83    let vh = 10f64.powf(g_db / 20.0);
84    let vb = vh.powf(0.499_666_774_155);
85    let a0 = 1.0 + k / q + k * k;
86    Biquad {
87        b0: (vh + vb * k / q + k * k) / a0,
88        b1: 2.0 * (k * k - vh) / a0,
89        b2: (vh - vb * k / q + k * k) / a0,
90        a1: 2.0 * (k * k - 1.0) / a0,
91        a2: (1.0 - k / q + k * k) / a0,
92    }
93}
94
95/// BS.1770 stage 2: highpass (~38.135 Hz, Q 0.50033).
96fn k_highpass(fs: f64) -> Biquad {
97    let (q, fc) = (0.500_327_037_323_877_3, 38.135_470_876_024_44);
98    let k = (std::f64::consts::PI * fc / fs).tan();
99    let a0 = 1.0 + k / q + k * k;
100    Biquad {
101        b0: 1.0 / a0,
102        b1: -2.0 / a0,
103        b2: 1.0 / a0,
104        a1: 2.0 * (k * k - 1.0) / a0,
105        a2: (1.0 - k / q + k * k) / a0,
106    }
107}
108
109/// Gated integrated loudness in LUFS. `None` when no block clears the
110/// −70 LUFS absolute gate (i.e. the signal is effectively silent).
111pub fn integrated_lufs(samples: &[f64], sample_rate: f64) -> Option<f64> {
112    let block = (0.4 * sample_rate) as usize; // 400 ms
113    let step = block / 4; // 75% overlap
114    if samples.len() < block || block == 0 {
115        return None;
116    }
117
118    // K-weight a copy.
119    let mut w = samples.to_vec();
120    k_shelf(sample_rate).process(&mut w);
121    k_highpass(sample_rate).process(&mut w);
122
123    // Block loudnesses.
124    let block_loudness: Vec<f64> = w
125        .windows(block)
126        .step_by(step)
127        .map(|b| {
128            let ms = b.iter().map(|s| s * s).sum::<f64>() / b.len() as f64;
129            -0.691 + 10.0 * (ms + 1e-30).log10()
130        })
131        .collect();
132
133    // Absolute gate at −70 LUFS.
134    let abs_gated: Vec<f64> = block_loudness
135        .iter()
136        .copied()
137        .filter(|l| *l > -70.0)
138        .collect();
139    if abs_gated.is_empty() {
140        return None;
141    }
142    let mean_energy = |ls: &[f64]| {
143        ls.iter()
144            .map(|l| 10f64.powf((l + 0.691) / 10.0))
145            .sum::<f64>()
146            / ls.len() as f64
147    };
148    // Relative gate 10 LU below the absolute-gated mean.
149    let rel_threshold = -0.691 + 10.0 * mean_energy(&abs_gated).log10() - 10.0;
150    let rel_gated: Vec<f64> = abs_gated
151        .into_iter()
152        .filter(|l| *l > rel_threshold)
153        .collect();
154    if rel_gated.is_empty() {
155        return None;
156    }
157    Some(-0.691 + 10.0 * mean_energy(&rel_gated).log10())
158}
159
160/// Result of loudness normalization.
161#[derive(Clone, Copy, Debug)]
162pub struct NormReport {
163    /// Integrated loudness before normalization.
164    pub lufs_before: f64,
165    /// Linear gain applied.
166    pub gain: f64,
167    /// Gain in dB, after both [`MAX_GAIN_DB`] and the [`PEAK_CEILING`] cap.
168    /// This is the number [`crate::render::render_playback`] replays.
169    pub gain_db: f64,
170    /// Peak absolute sample *before* the gain was applied.
171    pub peak_before: f64,
172    /// How much makeup gain was given up so the peak would clear
173    /// [`PEAK_CEILING`], in dB — always ≥ 0. Zero means the loudness target
174    /// was reached outright, which is the common case; a positive value means
175    /// this patch auditions below target because it is too peaky to reach it.
176    pub peak_reduction_db: f64,
177}
178
179/// Maximum boost applied during normalization — a very quiet patch is a vet
180/// problem, not something to amplify by 60 dB.
181pub const MAX_GAIN_DB: f64 = 30.0;
182
183/// Peak ceiling of the normalized buffer, in the nominal ±1.0 float domain.
184///
185/// Full scale, not a dB of headroom below it, because there is already margin
186/// downstream: every audible path runs through the app's `master.gain = 0.8`
187/// (≈1.9 dB), which is what absorbs the intersample peaks a resampling output
188/// device can produce from a buffer that is exactly at 1.0. Taking the margin
189/// once, downstream, rather than twice keeps this constant meaning the one
190/// thing it says — *no sample leaves here above full scale* — and keeps the
191/// loudness the trade is paid out of as high as it can be.
192pub const PEAK_CEILING: f64 = 1.0;
193
194/// Normalize `samples` in place toward the target integrated loudness, never
195/// exceeding [`PEAK_CEILING`].
196///
197/// Returns `None` (leaving samples untouched) when the signal is gated silent.
198pub fn normalize_to(samples: &mut [f64], sample_rate: f64, target_lufs: f64) -> Option<NormReport> {
199    let lufs = integrated_lufs(samples, sample_rate)?;
200    let wanted_db = (target_lufs - lufs).min(MAX_GAIN_DB);
201
202    // The peak is measured *before* the gain, so the headroom below is exactly
203    // the gain at which the loudest sample lands on the ceiling. Guarded on
204    // `peak > 0` rather than assumed: a buffer of exact zeros cannot reach the
205    // loudness gate above, but nothing here should depend on that reasoning
206    // holding somewhere else.
207    let peak_before = samples.iter().fold(0.0f64, |p, s| p.max(s.abs()));
208    let gain_db = if peak_before > 0.0 {
209        let headroom_db = 20.0 * (PEAK_CEILING / peak_before).log10();
210        wanted_db.min(headroom_db)
211    } else {
212        wanted_db
213    };
214    // Measured against what *loudness* wanted, not against unity: a patch that
215    // was already over the ceiling and also needed attenuating to reach the
216    // target reports only the extra the ceiling took, because the rest was
217    // going to happen anyway. Non-negative by construction — `gain_db` is a
218    // `min` of `wanted_db` — so this is a subtraction, not a clamp.
219    let peak_reduction_db = wanted_db - gain_db;
220
221    let gain = 10f64.powf(gain_db / 20.0);
222    for s in samples.iter_mut() {
223        *s *= gain;
224    }
225    Some(NormReport {
226        lufs_before: lufs,
227        gain,
228        gain_db,
229        peak_before,
230        peak_reduction_db,
231    })
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237
238    /// A phrase-length buffer at `amp`, with one sample spiked to `peak` — a
239    /// crest factor built to order.
240    fn peaky(amp: f64, peak: f64, sr: f64) -> Vec<f64> {
241        let n = (2.0 * sr) as usize;
242        // A 220 Hz sine, so the K-weighted loudness is a real measurement
243        // rather than an artifact of a square or of DC.
244        let mut v: Vec<f64> = (0..n)
245            .map(|i| amp * (std::f64::consts::TAU * 220.0 * i as f64 / sr).sin())
246            .collect();
247        v[n / 2] = peak;
248        v
249    }
250
251    /// **The defect, as a number.** A quiet, very peaky render asks for tens of
252    /// dB of makeup; without the ceiling it gets it, and the audition arrives
253    /// over full scale. The measured worst case over 150 prior draws was 4.06.
254    #[test]
255    fn a_peaky_render_never_leaves_above_full_scale() {
256        let sr = 44_100.0;
257        let mut x = peaky(0.02, 0.5, sr);
258        let r = normalize_to(&mut x, sr, -18.0).expect("not silent");
259        let peak = x.iter().fold(0.0f64, |p, s| p.max(s.abs()));
260        assert!(
261            peak <= PEAK_CEILING + 1e-12,
262            "normalized peak {peak} is over the ceiling"
263        );
264        // …and it says so, rather than reading as a patch that is simply quiet.
265        assert!(
266            r.peak_reduction_db > 0.0,
267            "the ceiling bound the gain but reported no reduction"
268        );
269    }
270
271    /// **An ordinary render must come out exactly as it did before the ceiling
272    /// existed.** The ceiling is a fault stop, not a level policy: if it moved
273    /// the gain of a patch that was never going to clip, it would be quietly
274    /// re-levelling the whole pool and every audio feature that is not
275    /// scale-invariant with it.
276    #[test]
277    fn a_render_with_headroom_is_untouched_by_the_ceiling() {
278        let sr = 44_100.0;
279        let mut x = peaky(0.1, 0.1, sr); // crest ≈ √2, nothing to catch
280        let r = normalize_to(&mut x, sr, -18.0).expect("not silent");
281        assert_eq!(r.peak_reduction_db, 0.0, "the ceiling bound a clean render");
282        assert_eq!(
283            r.gain_db,
284            (-18.0 - r.lufs_before).min(MAX_GAIN_DB),
285            "gain moved on a render that had headroom"
286        );
287    }
288
289    /// A render that is *already* over the ceiling and also over the loudness
290    /// target is attenuated by the loudness target, and the ceiling claims no
291    /// credit for it. The naive `wanted − got` would report a reduction here
292    /// and make every loud patch look peak-limited.
293    #[test]
294    fn attenuation_the_loudness_target_asked_for_is_not_charged_to_the_ceiling() {
295        let sr = 44_100.0;
296        let mut x = peaky(0.9, 0.95, sr); // loud, but crest ≈ 1.5
297        let r = normalize_to(&mut x, sr, -18.0).expect("not silent");
298        assert!(r.gain_db < 0.0, "a loud render should be attenuated");
299        assert_eq!(
300            r.peak_reduction_db, 0.0,
301            "loudness attenuation was charged to the peak ceiling"
302        );
303    }
304}