Skip to main content

Module loudness

Module loudness 

Source
Expand description

ITU-R BS.1770-style loudness measurement and normalization (mono).

Why LUFS and not plain RMS: preference data is poisoned by loudness — louder reliably wins A/B tests — so candidates must be matched on perceived loudness before audition and feature extraction. K-weighting (a high-shelf boost above ~1.7 kHz plus a ~38 Hz highpass) approximates the ear’s sensitivity, and 400 ms gated blocks keep silence and release tails from dragging the measurement down.

The filter coefficients are derived parametrically (RBJ bilinear transform) from the BS.1770 analog prototype — the same approach pyloudnorm uses — so any sample rate works, matching the spec’s published 48 kHz coefficients at 48 kHz.

§Loudness is a target, not a promise: the peak wins

Matching integrated loudness says nothing about the peak. Crest factor varies by tens of dB across this grammar — a pad and a pluck at the same LUFS are nowhere near the same peak — so normalizing to a target level sends percussive patches well over full scale. Measured over 150 vetted prior draws before PEAK_CEILING existed: 15 % of renders peaked above 1.0 and 8 % above 1.25 (which is where the app’s master.gain = 0.8 clips), with a worst case of 4.06 — 12 dB over.

That is not a cosmetic defect. Preference data is elicited on this exact buffer, so a clipped audition collects a vote about clipping rather than about the patch — which is precisely the confound loudness normalization exists to remove, one stage later and silent. The live voice was never exposed to it (auracle_wasm::live’s master limiter has always held a 0.98 ceiling); the offline path took the volt divisor and not the limiter.

The fix is a smaller gain, not a limiter. normalize_to gives up whatever makeup it has to for the peak to clear PEAK_CEILING, and reports how much in NormReport::peak_reduction_db. A limiter would hold the loudness target but reshape the waveform, which moves crest, flatness_mean and flux_mean as well as the RMS pair, and would need a second copy of itself inside render_playback kept in lockstep forever. A scalar keeps that replay bit-identical by construction and cannot change timbre at all.

What it costs, stated plainly: the ~15 % of patches that hit the ceiling sit below the loudness target, so they audition quieter than the rest. Loudness matching degrades exactly where crest is highest. That is the right trade — quieter is a smaller bias on a preference judgment than clipped — but it is a trade, and peak_reduction_db is on the record so a surface can say “pulled down 3.2 dB so it would not clip” instead of pretending the patch was simply quiet.

Structs§

NormReport
Result of loudness normalization.

Constants§

MAX_GAIN_DB
Maximum boost applied during normalization — a very quiet patch is a vet problem, not something to amplify by 60 dB.
PEAK_CEILING
Peak ceiling of the normalized buffer, in the nominal ±1.0 float domain.

Functions§

integrated_lufs
Gated integrated loudness in LUFS. None when no block clears the −70 LUFS absolute gate (i.e. the signal is effectively silent).
normalize_to
Normalize samples in place toward the target integrated loudness, never exceeding PEAK_CEILING.