- 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).
123 lines
5.1 KiB
JavaScript
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);
|