// How loud a recording is, measured the way broadcast measures it. // // THE VOICES CAME IN 14 DB APART. The smith at -34.8 LUFS, the baker at -20.7: // played as recorded, one letter is five times louder than the next and the // quiet ones vanish under the torch. So every voice is brought to one level // when it loads, from its own samples, and a file dropped in later is levelled // the same way without anybody writing a number down. // // NOT A PLAIN AVERAGE. Gated RMS agreed with a real loudness meter to within a // decibel for ten of the eleven voices and missed the well house keeper by // three: that voice is breathy and bright, and the ear hears high frequencies // louder than an average counts them. ITU-R BS.1770 exists for exactly that -- // a K-weighting filter shaped like the ear, then a gate that ignores the // silences -- so this is BS.1770-4 integrated loudness, checked against // ffmpeg's ebur128 on every voice in the game. /** Biquad coefficients for the two K-weighting stages, at any sample rate. */ function kWeighting(fs) { // Stage 1: the head as an acoustic shelf, +4 dB above ~1.7 kHz. let K = Math.tan(Math.PI * 1681.974450955533 / fs); const Q1 = 0.7071752369554196; const Vh = 10 ** (3.999843853973347 / 20); const Vb = Vh ** 0.4996667741545416; let a0 = 1 + K / Q1 + K * K; const shelf = { b: [(Vh + Vb * K / Q1 + K * K) / a0, 2 * (K * K - Vh) / a0, (Vh - Vb * K / Q1 + K * K) / a0], a: [2 * (K * K - 1) / a0, (1 - K / Q1 + K * K) / a0], }; // Stage 2: the RLB high-pass, which stops rumble counting as loudness. K = Math.tan(Math.PI * 38.13547087602444 / fs); const Q2 = 0.5003270373238773; a0 = 1 + K / Q2 + K * K; const highpass = { b: [1, -2, 1], a: [2 * (K * K - 1) / a0, (1 - K / Q2 + K * K) / a0], }; return [shelf, highpass]; } function filter(x, { b, a }) { const y = new Float32Array(x.length); let x1 = 0, x2 = 0, y1 = 0, y2 = 0; for (let i = 0; i < x.length; i++) { const v = b[0] * x[i] + b[1] * x1 + b[2] * x2 - a[0] * y1 - a[1] * y2; x2 = x1; x1 = x[i]; y2 = y1; y1 = v; y[i] = v; } return y; } /** * Integrated loudness in LUFS of one mono channel, BS.1770-4: K-weight, * 400ms blocks overlapping by 75%, an absolute gate at -70 LUFS and a relative * gate 10 LU under the result. -Infinity for silence. */ export function loudness(samples, sampleRate) { const [shelf, highpass] = kWeighting(sampleRate); const y = filter(filter(samples, shelf), highpass); const block = Math.round(0.4 * sampleRate); const hop = Math.round(0.1 * sampleRate); const powers = []; for (let i = 0; i + block <= y.length; i += hop) { let e = 0; for (let j = i; j < i + block; j++) e += y[j] * y[j]; powers.push(e / block); } const lufs = (p) => -0.691 + 10 * Math.log10(p); const mean = (list) => list.reduce((s, p) => s + p, 0) / list.length; const loud = powers.filter((p) => lufs(p) > -70); if (!loud.length) return -Infinity; const gate = lufs(mean(loud)) - 10; const kept = loud.filter((p) => lufs(p) > gate); return lufs(mean(kept)); } /** The largest absolute sample, for keeping a level change clear of clipping. */ export function peak(samples) { let m = 0; for (let i = 0; i < samples.length; i++) { const v = Math.abs(samples[i]); if (v > m) m = v; } return m; }