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).
This commit is contained in:
Jonathan Sykes
2026-10-03 07:44:44 +08:00
parent 796293c0a5
commit ea9e11d054
10 changed files with 434 additions and 4 deletions

View File

@@ -628,6 +628,7 @@ const DEFAULT_SETTINGS = {
shuffle: false, // shuffle upcoming tracks when playing a list
theme: 'dark', // 'dark' | 'light' | 'contrast'
fontScale: 'normal', // 'small' | 'normal' | 'large' | 'xl'
levelVolume: null, // loudness levelling; null = default (on, except iOS where it is opt-in)
parallelSaves: 4, // offline saves running at once (Settings → Offline cache)
density: 'comfortable', // 'comfortable' | 'compact'
perfMode: false, // disable heavy visual effects for speed
@@ -2307,6 +2308,7 @@ const Player = {
addToHistory(current.meta);
Notes.load(current.meta);
Wave.load(current.meta);
Level.onTrack(current.meta);
Gestures.reset();
Party.afterLoad();
Remote.changed();
@@ -6315,6 +6317,99 @@ const Share = (() => {
return { open, bootFromUrl };
})();
// ============================================================================
// Loudness levelling (EBU R128) — every track toward -14 LUFS
// ============================================================================
// Loudness is measured on the device (loudness.js: WebAssembly K-weighting),
// from audio it already has or can get cheaply (the saved copy, or the server
// cache's small audio sidecar), then shared through /api/loudness so each song
// is measured once for everyone. The server never processes audio for this.
const Level = (() => {
const KEY = 'ytpLoudness';
const MAX_SECONDS = 12 * 60, MAX_BYTES = 60 * 1024 * 1024, RATE = 22050;
let seq = 0;
const read = () => { try { return JSON.parse(localStorage.getItem(KEY) || '{}'); } catch { return {}; } };
const remember = (id, lufs) => {
try {
const m = read(); m[id] = Math.round(lufs * 10) / 10;
const ids = Object.keys(m); if (ids.length > 3000) ids.slice(0, ids.length - 3000).forEach((k) => delete m[k]);
localStorage.setItem(KEY, JSON.stringify(m));
} catch { /* storage blocked */ }
};
const enabled = () => {
const v = data.settings.levelVolume;
if (v === true || v === false) return v;
return !EQ.isIOS(); // default: on, except iPhone/iPad where it is opt-in
};
const gainFor = (lufs) => Math.max(-12, Math.min(9, Loudness.TARGET - lufs));
function apply(lufs) {
if (!enabled() || lufs == null) { EQ.setLevel(0); return; }
EQ.setLevel(gainFor(lufs));
}
async function audioBytes(meta) {
const id = meta.id;
if (cachedIds.has(id) && window.OPFS) {
const f = await window.OPFS.getFileObject(id).catch(() => null);
if (f && f.size <= MAX_BYTES * 4) return f.arrayBuffer(); // decode reads the audio track only
}
if (isUploadId(id)) return null;
const st = await fetch(`/api/media/${encodeURIComponent(id)}/status`).then((r) => r.json()).catch(() => null);
if (!st || st.status !== 'ready') return null; // stream-only: no extra YouTube traffic for this
const r = await fetch(`/api/media/${encodeURIComponent(id)}?a=1`);
if (!r.ok) return null;
const len = Number(r.headers.get('content-length')) || 0;
if (len > MAX_BYTES) return null;
return r.arrayBuffer();
}
async function measure(meta) {
if (!window.Loudness || typeof OfflineAudioContext === 'undefined' && typeof webkitOfflineAudioContext === 'undefined') return null;
if ((meta.duration || 0) > MAX_SECONDS) return null;
const buf = await audioBytes(meta);
if (!buf) return null;
const OAC = window.OfflineAudioContext || window.webkitOfflineAudioContext;
const ctx = new OAC(2, RATE, RATE);
const audio = await new Promise((res, rej) => { const p = ctx.decodeAudioData(buf, res, rej); if (p && p.then) p.then(res, rej); });
if (!audio || audio.duration > MAX_SECONDS + 5) return null;
const ch = [audio.getChannelData(0)];
if (audio.numberOfChannels > 1) ch.push(audio.getChannelData(1));
return Loudness.measureChannels(ch, audio.sampleRate);
}
async function onTrack(meta) {
const my = ++seq;
if (!meta || !meta.id || meta.custom) { apply(null); return; }
const id = meta.id;
const local = read()[id];
if (local != null) { apply(local); return; }
apply(null);
if (!enabled()) return;
try {
const j = await fetch(`/api/loudness?v=${encodeURIComponent(id)}`).then((r) => r.json());
if (my !== seq) return;
if (j && j.lufs != null) { remember(id, j.lufs); apply(j.lufs); return; }
} catch { /* offline: measure below */ }
// Unknown: measure once the track has played a few seconds, when idle.
setTimeout(() => {
if (my !== seq) return;
const run = async () => {
try {
const lufs = await measure(meta);
if (lufs == null) return;
remember(id, lufs);
fetch('/api/loudness', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id, lufs }) }).catch(() => {});
if (my === seq) apply(lufs);
} catch { /* undecodable — leave it at 0 dB */ }
};
if (window.requestIdleCallback) requestIdleCallback(run, { timeout: 4000 }); else run();
}, 6000);
}
return { onTrack, enabled, apply, gainFor, remembered: (id) => read()[id] };
})();
// ============================================================================
// Picture-in-picture
// ============================================================================
@@ -6384,7 +6479,9 @@ const EQ = (() => {
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
const AC = window.AudioContext || window.webkitAudioContext;
const st = { ctx: null, filters: null, sources: new Map() };
const available = () => !!AC && !isIOS;
// Web Audio is off on iOS (it silences playback when the screen locks)
// unless the user opted in through "Level volume" (Settings → Playback).
const available = () => !!AC && (!isIOS || data.settings.levelVolume === true);
const gains = () => {
const g = data.settings.eq && Array.isArray(data.settings.eq.g) ? data.settings.eq.g : EQ_PRESETS.flat.g;
return EQ_BANDS.map((_, i) => Math.max(-12, Math.min(12, Number(g[i]) || 0)));
@@ -6403,7 +6500,15 @@ const EQ = (() => {
return f;
});
for (let i = 0; i < st.filters.length - 1; i++) st.filters[i].connect(st.filters[i + 1]);
st.filters[st.filters.length - 1].connect(st.ctx.destination);
// Loudness levelling: gain toward -14 LUFS, then a limiter so a boosted
// quiet track can never clip.
st.level = st.ctx.createGain();
st.limiter = st.ctx.createDynamicsCompressor();
st.limiter.threshold.value = -1; st.limiter.knee.value = 0; st.limiter.ratio.value = 20;
st.limiter.attack.value = 0.003; st.limiter.release.value = 0.25;
st.filters[st.filters.length - 1].connect(st.level);
st.level.connect(st.limiter);
st.limiter.connect(st.ctx.destination);
return true;
} catch { st.ctx = null; return false; }
}
@@ -6507,7 +6612,21 @@ const EQ = (() => {
return st.analyser;
}
return { open, apply, renderButton, available, analyser, state: () => ({ wired: !!st.ctx, ctx: st.ctx && st.ctx.state, gains: st.filters ? st.filters.map((f) => f.gain.value) : null }) };
// Set the levelling gain (dB). 0 dB never wires the graph on its own.
function setLevel(db) {
if (!available()) return false;
if (!st.ctx && Math.abs(db) < 0.3) return true;
if (!build()) return false;
attach(els.video);
attach(els.audio);
if (st.ctx.state === 'suspended') st.ctx.resume().catch(() => {});
const g = Math.pow(10, db / 20);
st.level.gain.setTargetAtTime(g, st.ctx.currentTime, 0.4);
return true;
}
return { open, apply, renderButton, available, analyser, setLevel, isIOS: () => isIOS, state: () => ({ wired: !!st.ctx, ctx: st.ctx && st.ctx.state, gains: st.filters ? st.filters.map((f) => f.gain.value) : null,
levelDb: st.level ? Math.round(200 * Math.log10(Math.max(1e-6, st.level.gain.value))) / 10 : null }) };
})();
// ============================================================================
@@ -9112,6 +9231,15 @@ async function renderSettings() {
<span>Loop the current video</span>
<input id="setLoopOne" type="checkbox" ${data.settings.loopOne ? 'checked' : ''} />
</label>
<label class="set-row">
<span>
Level volume between songs
<small>${EQ.isIOS()
? 'Brings every song to the same loudness (−14 LUFS). On iPhone this stops the sound when the screen locks.'
: 'Brings every song to the same loudness (−14 LUFS), measured on this device and shared.'}</small>
</span>
<input id="setLevelVolume" type="checkbox" ${Level.enabled() ? 'checked' : ''} />
</label>
<label class="set-row">
<span>Default volume</span>
<input id="setVolume" type="range" min="0" max="1" step="0.01" value="${data.settings.volume}" />
@@ -9334,6 +9462,12 @@ async function renderSettings() {
$('setMotion').addEventListener('change', (e) => { data.settings.reduceMotion = e.target.checked; applyAppearance(); persist(); });
$('setRepeat').addEventListener('change', (e) => { data.settings.repeatMode = e.target.checked ? 'all' : 'off'; updateLoopRepeatButtons(); persist(); });
$('setLoopOne').addEventListener('change', (e) => { data.settings.loopOne = e.target.checked; updateLoopRepeatButtons(); persist(); });
$('setLevelVolume').addEventListener('change', (e) => {
data.settings.levelVolume = e.target.checked;
persist();
if (current && current.meta) Level.onTrack(current.meta); else EQ.setLevel(0);
if (e.target.checked && EQ.isIOS()) toast('Volume levelling on — sound stops when the screen locks', { duration: 4000 });
});
// ---- Saved searches manager ----

View File

@@ -644,6 +644,8 @@
<script src="stats-core.js"></script>
<script src="sha256-wasm.js"></script>
<script src="sha256.js"></script>
<script src="loudness-wasm.js"></script>
<script src="loudness.js"></script>
<script src="device-db.js"></script>
<script src="p2p-client.js"></script>
<script src="p2p-core.js"></script>

View File

@@ -0,0 +1,6 @@
/* GENERATED by scripts/build-loudness-wasm.sh from frontend/wasm/loudness.c — do not edit.
* WebAssembly K-weighting + 100 ms energy steps for EBU R128 loudness (see loudness.js). */
(function (root) {
root.LOUDNESS_WASM_B64 = 'AGFzbQEAAAABHQRgAX8Bf2AAAX9gDH9/fHx8fHx8fHx8fABgAX8AAwYFAAEBAgMFAwEAQgYIAX8BQYDAAAsHMQYGbWVtb3J5AgAFaW5idWYAAAVzdGVwcwABBWNvdW50AAIFc2V0dXAAAwRmZWVkAAQK2QoFEQBBgMCAgABBgMCQgAAgABsLCABBgMCggAALCwBBACgCgOjjgQAL5wIAQQBCADcDkOjjgQBBACACOQOg6OOBAEEAIAM5A6jo44EAQQAgBDkDsOjjgQBBACAFOQO46OOBAEEAIAY5A8Do44EAQQAgBzkD0OjjgQBBACAIOQPY6OOBAEEAIAk5A+Do44EAQQAgCjkD6OjjgQBBACALOQPw6OOBAEEAQgA3A/jp44EAQQBCADcD8OnjgQBBAEIANwPo6eOBAEEAQgA3A+Dp44EAQQBCADcD2OnjgQBBAEIANwPQ6eOBAEEAQgA3A8jp44EAQQBCADcDwOnjgQBBAEIANwO46eOBAEEAQgA3A7Dp44EAQQBCADcDqOnjgQBBAEIANwOg6eOBAEEAQgA3A5jp44EAQQBCADcDkOnjgQBBAEIANwOI6eOBAEEAQgA3A4Dp44EAQQAgAUEBIAFBAUobNgKI6OOBAEEAQQJBASAAQQFKGzYChOjjgQBBAEEANgKM6OOBAEEAQQA2AoDo44EAC8YHDgN/CHwBfwd8An8EfAJ/BHwCfwF8An8BfAF/A3wCQCAAQQFIDQAgAEGAgAQgAEGAgARIGyEBQQAoAoTo44EAIgJBf2ohA0EAKwO46eOBACEEQQArA7Dp44EAIQVBACsDqOnjgQAhBkEAKwOg6eOBACEHQQArA5jp44EAIQhBACsDkOnjgQAhCUEAKwOI6eOBACEKQQArA4Dp44EAIQtBACgCiOjjgQAhDEEAKwPg6OOBACENQQArA9jo44EAIQ5BACsD0OjjgQAhD0EAKwOw6OOBACEQQQArA6jo44EAIRFBACsDoOjjgQAhEkEAKwOQ6OOBACETQQAoAozo44EAIRRBACgCgOjjgQAhFUEAKwPw6OOBAJohFkEAKwPo6OOBAJohF0EAKwPA6OOBAJohGEEAKwO46OOBAJohGSACQQFIIRpBACEbA0ACQAJAIBpFDQBEAAAAAAAAAAAhHAwBC0EAIAs5A4jp44EAQQAgCTkDmOnjgQBBACAHOQOo6eOBAEEAIAU5A7jp44EAQQAgG0ECdCIAKgKAwJCAALsiHTkDgOnjgQBBACAYIAiiIBkgCaIgECAKoiASIB2iIBEgC6KgoKCgIh45A5Dp44EAQQAgHjkDoOnjgQBBACAWIASiIBcgBaIgDSAGoiAPIB6iIA4gB6KgoKCgIh85A7Dp44EAIB8gH6JEAAAAAAAAAACgIRwCQCACQQFGDQAgACoCgMCAgAC7IQRBgOnjgQAhACADISADQCAAQcgAaiIhKwMAISIgISAAQcAAaiIjKwMAIgY5AwAgAEHYAGoiISsDACEIICEgAEHQAGoiJCsDACIKOQMAICMgBDkDACAAQegAaiIhKwMAISUgISAAQeAAaiImKwMAIic5AwAgAEH4AGoiISsDACEoICEgAEHwAGoiACsDACIpOQMAICQgGCAIoiAZIAqiIBAgIqIgEiAEoiARIAaioKCgoCIiOQMAICYgIjkDACAAIBYgKKIgFyApoiANICWiIA8gIqIgDiAnoqCgoKAiIjkDACAiICKiIBygIRwgIyEAICBBf2oiIA0ACwsgBSEEIB8hBSAHIQYgHiEHIAkhCCAeIQkgCyEKIB0hCwsgHCAToCEiAkACQCAUQQFqIhQgDEYNACAiIRMMAQtEAAAAAAAAAAAhE0EAIRQgFUH/tBhKDQBBACEUQQAgFUEBaiIANgKA6OOBACAVQQN0QYDAoIAAaiAiOQMAIAAhFQsgG0EBaiIbIAFHDQALQQAgFDYCjOjjgQBBACATOQOQ6OOBAAsL';
if (typeof module !== 'undefined' && module.exports) module.exports = root.LOUDNESS_WASM_B64;
})(typeof globalThis !== 'undefined' ? globalThis : this);

122
frontend/loudness.js Normal file
View File

@@ -0,0 +1,122 @@
/* ============================================================================
* 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);

37
frontend/loudness.test.js Normal file
View File

@@ -0,0 +1,37 @@
'use strict';
const { test } = require('node:test');
const assert = require('node:assert');
globalThis.LOUDNESS_WASM_B64 = require('./loudness-wasm.js');
const L = require('./loudness');
const sine = (amp, freq, fs, secs) => { const a = new Float32Array(fs * secs); for (let i = 0; i < a.length; i++) a[i] = amp * Math.sin(2 * Math.PI * freq * i / fs); return a; };
const dbfs = (db) => Math.pow(10, db / 20);
test('EBU Tech 3341 case: 1 kHz stereo sine at -23 dBFS measures -23.0 LUFS (wasm and js)', () => {
for (const fs of [48000, 44100, 22050]) {
const s = sine(dbfs(-23), 1000, fs, 20);
const w = L.measureChannels([s, s], fs);
const j = L.measureChannels([s, s], fs, { prefer: 'js' });
assert.ok(Math.abs(w - -23) < 0.1, `wasm ${fs}: ${w}`);
assert.ok(Math.abs(j - w) < 1e-6, `js ${j} vs wasm ${w}`);
}
assert.strictEqual(L.engineName(), 'wasm');
});
test('EBU Tech 3341 case: -33 dBFS measures -33.0 LUFS', () => {
const s = sine(dbfs(-33), 1000, 48000, 20);
assert.ok(Math.abs(L.measureChannels([s, s], 48000) - -33) < 0.1);
});
test('gating ignores silence around the music', () => {
const fs = 48000, s = sine(dbfs(-23), 1000, fs, 10);
const padded = new Float32Array(fs * 30); padded.set(s, fs * 10);
// 20 s of silence would read about -27.8 ungated; only the partial 400 ms
// blocks at the tone's edges still count, which is correct R128 behaviour.
assert.ok(Math.abs(L.measureChannels([padded, padded], fs) - -23) < 0.2);
});
test('silence is null', () => {
const z = new Float32Array(48000 * 5);
assert.strictEqual(L.measureChannels([z, z], 48000), null);
});

View File

@@ -73,6 +73,8 @@ const SHELL = [
'/stats-core.js',
'/sha256.js',
'/resume-core.js',
'/loudness-wasm.js',
'/loudness.js',
'/sha256-wasm.js',
'/device-db.js',
'/hash-worker.js',

54
frontend/wasm/loudness.c Normal file
View File

@@ -0,0 +1,54 @@
/* loudness.c — EBU R128 / ITU-R BS.1770 K-weighting + 100 ms energy steps.
*
* The heavy per-sample part of integrated-loudness measurement, compiled to
* WebAssembly (scripts/build-loudness-wasm.sh → frontend/loudness-wasm.js).
* JavaScript computes the filter coefficients for the sample rate, feeds the
* decoded audio in chunks through inbuf(), then reads the per-step energies
* and applies the 400 ms block gating (absolute -70 LUFS, relative -10 LU).
* No libc: only multiply-adds here. */
#define CHUNK 65536
#define MAXSTEPS 400000 /* 100 ms steps: ~11 hours */
static float in0[CHUNK], in1[CHUNK];
static double stepE[MAXSTEPS];
static int nsteps, nch, stepLen, stepPos;
static double acc;
static double k1[5], k2[5]; /* b0 b1 b2 a1 a2 for each stage */
static double st[2][8]; /* per channel: stage1 x1 x2 y1 y2, stage2 x1 x2 y1 y2 */
float *inbuf(int ch) { return ch ? in1 : in0; }
double *steps(void) { return stepE; }
int count(void) { return nsteps; }
void setup(int channels, int steplen,
double a0, double a1, double a2, double a3, double a4,
double b0, double b1, double b2, double b3, double b4) {
nch = channels > 1 ? 2 : 1;
stepLen = steplen > 0 ? steplen : 1;
stepPos = 0; nsteps = 0; acc = 0;
k1[0] = a0; k1[1] = a1; k1[2] = a2; k1[3] = a3; k1[4] = a4;
k2[0] = b0; k2[1] = b1; k2[2] = b2; k2[3] = b3; k2[4] = b4;
for (int c = 0; c < 2; c++) for (int j = 0; j < 8; j++) st[c][j] = 0;
}
void feed(int n) {
if (n > CHUNK) n = CHUNK;
for (int i = 0; i < n; i++) {
double e = 0;
for (int c = 0; c < nch; c++) {
double *s = st[c];
double x = c ? in1[i] : in0[i];
double 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;
double 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) {
if (nsteps < MAXSTEPS) stepE[nsteps++] = acc;
acc = 0; stepPos = 0;
}
}
}

27
scripts/build-loudness-wasm.sh Executable file
View File

@@ -0,0 +1,27 @@
#!/usr/bin/env bash
# Rebuild frontend/loudness-wasm.js (base64-embedded WebAssembly loudness core)
# from frontend/wasm/loudness.c. Uses clang (wasm32 + wasm-ld) when present,
# else Zig's bundled clang: `uv pip install ziglang` into a venv and set
# ZIG="<venv>/bin/python -m ziglang" (default: ~/.local/share/ytp-zig).
set -euo pipefail
cd "$(dirname "$0")/.."
tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
FLAGS=(-O3 -nostdlib -fno-builtin -ffreestanding
-Wl,--no-entry -Wl,--export=inbuf -Wl,--export=steps -Wl,--export=count -Wl,--export=setup -Wl,--export=feed
-Wl,--export-memory -Wl,--initial-memory=4325376 -Wl,-z,stack-size=8192 -Wl,--strip-all)
if command -v clang >/dev/null && clang --print-targets 2>/dev/null | grep -q wasm32; then
clang --target=wasm32 "${FLAGS[@]}" frontend/wasm/loudness.c -o "$tmp/l.wasm"
else
ZIG=${ZIG:-"$HOME/.local/share/ytp-zig/bin/python -m ziglang"}
$ZIG cc -target wasm32-freestanding "${FLAGS[@]}" frontend/wasm/loudness.c -o "$tmp/l.wasm"
fi
b64=$(base64 -w0 "$tmp/l.wasm")
cat > frontend/loudness-wasm.js <<JS
/* GENERATED by scripts/build-loudness-wasm.sh from frontend/wasm/loudness.c — do not edit.
* WebAssembly K-weighting + 100 ms energy steps for EBU R128 loudness (see loudness.js). */
(function (root) {
root.LOUDNESS_WASM_B64 = '$b64';
if (typeof module !== 'undefined' && module.exports) module.exports = root.LOUDNESS_WASM_B64;
})(typeof globalThis !== 'undefined' ? globalThis : this);
JS
echo "wasm: $(wc -c < "$tmp/l.wasm") bytes"

View File

@@ -240,6 +240,14 @@ export async function initDb() {
for (const col of ['owner TEXT', 'listed INTEGER NOT NULL DEFAULT 1']) {
try { await db.execute(`ALTER TABLE uploads ADD COLUMN ${col}`); } catch { /* already there */ }
}
// Integrated loudness (EBU R128 LUFS) measured by devices in the browser and
// shared, so each song is measured once for everyone.
await db.execute(`CREATE TABLE IF NOT EXISTS loudness (
id TEXT PRIMARY KEY,
lufs REAL NOT NULL,
n INTEGER NOT NULL DEFAULT 1,
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
)`);
// Chapters YouTube publishes for a video (from yt-dlp's `chapters`), cached so
// a server-cached copy can show them without another yt-dlp call.
await db.execute(`CREATE TABLE IF NOT EXISTS yt_chapters (
@@ -846,3 +854,20 @@ export async function setYtChapters(id, items) {
args: [id, JSON.stringify(items || [])],
});
}
// ---- Shared loudness (device-measured) --------------------------------------
export async function getLoudness(id) {
const r = await db.execute({ sql: 'SELECT lufs, n FROM loudness WHERE id = ?', args: [id] });
return r.rows[0] ? { lufs: Number(r.rows[0].lufs), n: Number(r.rows[0].n) } : null;
}
// Running mean of the first 10 reports, then it stops moving.
export async function addLoudness(id, lufs) {
await db.execute({
sql: `INSERT INTO loudness (id, lufs, n, updated_at) VALUES (?, ?, 1, unixepoch())
ON CONFLICT(id) DO UPDATE SET
lufs = CASE WHEN n >= 10 THEN lufs ELSE (lufs * n + excluded.lufs) / (n + 1) END,
n = CASE WHEN n >= 10 THEN n ELSE n + 1 END,
updated_at = unixepoch()`,
args: [id, lufs],
});
}

View File

@@ -40,7 +40,7 @@ import { Readable } from 'node:stream';
import { tmpdir } from 'node:os';
import { createHash } from 'node:crypto';
import { brotliCompressSync, constants as zlibConstants } from 'node:zlib';
import { initDb, upsertUser, recordVideoAccess, getUserData, createProfile, getProfile, saveProfile, setProfileSecret, getYtChapters, setYtChapters, createSharedPlaylist, getSharedPlaylist, queueInboxPlaylist, listInbox, deleteInboxItem, countInbox,
import { initDb, upsertUser, recordVideoAccess, getUserData, createProfile, getProfile, saveProfile, setProfileSecret, getYtChapters, setYtChapters, getLoudness, addLoudness, createSharedPlaylist, getSharedPlaylist, queueInboxPlaylist, listInbox, deleteInboxItem, countInbox,
getMedia, upsertMedia, deleteMedia, listMedia, listMediaLru, touchMedia, mediaStats } from './db.js';
import { createMediaCache, HIGH, LOW, validateMedia, MediaSkip } from './media-cache.js';
import * as notesDb from './db.js';
@@ -766,6 +766,27 @@ function countView(c, videoId) {
}
// GET /api/streams?v=<videoId> — meta + proxied audio/quality URLs.
// Loudness levelling (EBU R128). Devices measure in the browser (WebAssembly)
// and report here; the server only stores numbers — no audio processing.
// GET /api/loudness?v=<id> → { lufs | null }
// POST /api/loudness { id, lufs } → stored (running mean of 10 reports)
app.get('/api/loudness', async (c) => {
const id = (c.req.query('v') || '').trim();
if (!media.isMediaId(id) && !isUpload(id)) return c.json({ ok: true, lufs: null });
const row = await getLoudness(id).catch(() => null);
return c.json({ ok: true, lufs: row ? Math.round(row.lufs * 10) / 10 : null }, 200, { 'Cache-Control': 'no-store' });
});
app.post('/api/loudness', async (c) => {
let body;
try { body = await c.req.json(); } catch { return c.json({ ok: false, error: 'invalid JSON' }, 400); }
const id = String(body && body.id || '').trim();
const lufs = Number(body && body.lufs);
if (!media.isMediaId(id) && !isUpload(id)) return c.json({ ok: false, error: 'invalid id' }, 400);
if (!Number.isFinite(lufs) || lufs < -60 || lufs > 0) return c.json({ ok: false, error: 'lufs out of range' }, 400);
await addLoudness(id, lufs);
return c.json({ ok: true });
});
// GET /api/chapters?v=<id> — YouTube's chapters for a video ([] when it has
// none). Served from the cache table; otherwise resolved once (the stream
// resolver's own cache usually makes that free) and remembered for 30 days.