Files
ytplayer/frontend/loudness.js
Jonathan Sykes ea9e11d054 Loudness levelling (EBU R128, -14 LUFS) measured in the browser with WebAssembly
- frontend/wasm/loudness.c → loudness-wasm.js (1.5 KB, base64-embedded; built by
  scripts/build-loudness-wasm.sh with clang or Zig's clang): K-weighting + 100 ms
  energy steps. loudness.js does the 400 ms gating (abs -70 LUFS, rel -10 LU)
  with an identical JS fallback. EBU Tech 3341 sine cases measure -23.0/-33.0
  on both engines; a real track reads -21.7 vs ffmpeg ebur128 -21.8.
- The device measures from audio it already has (saved copy or the server
  cache's m4a sidecar, songs up to 12 min, decoded natively at 22.05 kHz) and
  reports the number to /api/loudness, so each song is measured once for
  everyone; the server stores numbers only, no audio processing.
- The EQ graph gains a level stage (gain -> limiter) that eases each track
  toward -14 LUFS (-12..+9 dB). Settings -> Playback -> Level volume: on by
  default on Android/desktop, opt-in on iPhone (Web Audio stops sound when the
  screen locks there).
2026-10-03 07:44:44 +08:00

123 lines
5.1 KiB
JavaScript

/* ============================================================================
* loudness — EBU R128 integrated loudness (LUFS), measured in the browser.
*
* The per-sample work (two K-weighting biquads + 100 ms energy steps) runs in
* WebAssembly (loudness-wasm.js, built from wasm/loudness.c); a plain JS loop
* with identical math is the fallback. Gating (400 ms blocks, 75 % overlap,
* absolute -70 LUFS, relative -10 LU) is done here on the step energies.
*
* Plain <script> global `Loudness` in the browser; `require`-able by node.
* ========================================================================== */
(function (root) {
'use strict';
const CHUNK = 65536;
// ITU-R BS.1770-4 K-weighting, recomputed for any sample rate.
function coefficients(fs) {
let f0 = 1681.974450955533, G = 3.999843853973347, Q = 0.7071752369554196;
let K = Math.tan(Math.PI * f0 / fs);
const Vh = Math.pow(10, G / 20), Vb = Math.pow(Vh, 0.4996667741545416);
let a0 = 1 + K / Q + K * K;
const s1 = [(Vh + Vb * K / Q + K * K) / a0, 2 * (K * K - Vh) / a0, (Vh - Vb * K / Q + K * K) / a0,
2 * (K * K - 1) / a0, (1 - K / Q + K * K) / a0];
f0 = 38.13547087602444; Q = 0.5003270373238773;
K = Math.tan(Math.PI * f0 / fs);
a0 = 1 + K / Q + K * K;
const s2 = [1, -2, 1, 2 * (K * K - 1) / a0, (1 - K / Q + K * K) / a0];
return [s1, s2];
}
// Gating over per-100 ms energies (already summed over channels).
function integrate(stepE, n, stepLen) {
const blocks = [];
for (let i = 0; i + 4 <= n; i++) {
const z = (stepE[i] + stepE[i + 1] + stepE[i + 2] + stepE[i + 3]) / (4 * stepLen);
if (z > 0) blocks.push(z);
}
const L = (z) => -0.691 + 10 * Math.log10(z);
const abs = blocks.filter((z) => L(z) > -70);
if (!abs.length) return null;
const rel = L(abs.reduce((a, b) => a + b, 0) / abs.length) - 10;
const gated = abs.filter((z) => L(z) > rel);
if (!gated.length) return null;
return L(gated.reduce((a, b) => a + b, 0) / gated.length);
}
// ---- engines: { setup(nch, stepLen, s1, s2), input(ch) → Float32Array, feed(n), result() } ----
function wasmEngine() {
const b64 = root.LOUDNESS_WASM_B64;
if (!b64 || typeof WebAssembly === 'undefined') return null;
try {
const bin = typeof atob === 'function'
? Uint8Array.from(atob(b64), (c) => c.charCodeAt(0))
: Uint8Array.from(Buffer.from(b64, 'base64'));
const inst = new WebAssembly.Instance(new WebAssembly.Module(bin), {});
const x = inst.exports;
const mem = () => x.memory.buffer;
return {
engine: 'wasm',
setup(nch, stepLen, s1, s2) { x.setup(nch, stepLen, ...s1, ...s2); },
input(ch) { return new Float32Array(mem(), x.inbuf(ch), CHUNK); },
feed(n) { x.feed(n); },
steps() { return [new Float64Array(mem(), x.steps(), x.count()), x.count()]; },
};
} catch { return null; }
}
function jsEngine() {
const buf = [new Float32Array(CHUNK), new Float32Array(CHUNK)];
let nch = 1, stepLen = 1, stepPos = 0, acc = 0, k1, k2, st;
const out = [];
return {
engine: 'js',
setup(c, sl, s1, s2) { nch = c > 1 ? 2 : 1; stepLen = sl; stepPos = 0; acc = 0; k1 = s1; k2 = s2; st = [new Float64Array(8), new Float64Array(8)]; out.length = 0; },
input(ch) { return buf[ch]; },
feed(n) {
for (let i = 0; i < n; i++) {
let e = 0;
for (let c = 0; c < nch; c++) {
const s = st[c], x = buf[c][i];
const y = k1[0] * x + k1[1] * s[0] + k1[2] * s[1] - k1[3] * s[2] - k1[4] * s[3];
s[1] = s[0]; s[0] = x; s[3] = s[2]; s[2] = y;
const z = k2[0] * y + k2[1] * s[4] + k2[2] * s[5] - k2[3] * s[6] - k2[4] * s[7];
s[5] = s[4]; s[4] = y; s[7] = s[6]; s[6] = z;
e += z * z;
}
acc += e;
if (++stepPos === stepLen) { out.push(acc); acc = 0; stepPos = 0; }
}
},
steps() { return [out, out.length]; },
};
}
let _engine = null;
function engine(prefer) {
if (prefer === 'js') return jsEngine();
if (!_engine) _engine = wasmEngine() || jsEngine();
return _engine;
}
/** Integrated loudness (LUFS) of channel data, or null for silence. */
function measureChannels(channels, sampleRate, { prefer } = {}) {
const e = engine(prefer);
const nch = Math.min(2, channels.length);
const stepLen = Math.max(1, Math.round(sampleRate / 10));
const [s1, s2] = coefficients(sampleRate);
e.setup(nch, stepLen, s1, s2);
const len = channels[0].length;
for (let pos = 0; pos < len; pos += CHUNK) {
const n = Math.min(CHUNK, len - pos);
for (let c = 0; c < nch; c++) e.input(c).set(channels[c].subarray(pos, pos + n));
e.feed(n);
}
const [steps, n] = e.steps();
return integrate(steps, n, stepLen);
}
const Loudness = { coefficients, integrate, measureChannels, engineName: () => engine().engine, TARGET: -14 };
if (typeof module !== 'undefined' && module.exports) module.exports = Loudness;
else root.Loudness = Loudness;
})(typeof globalThis !== 'undefined' ? globalThis : this);