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}