From ea9e11d0541249e76c904b02adce8a15cd95d3f8 Mon Sep 17 00:00:00 2001 From: Jonathan Sykes Date: Sat, 3 Oct 2026 07:44:44 +0800 Subject: [PATCH] Loudness levelling (EBU R128, -14 LUFS) measured in the browser with WebAssembly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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). --- frontend/app.js | 140 ++++++++++++++++++++++++++++++++- frontend/index.html | 2 + frontend/loudness-wasm.js | 6 ++ frontend/loudness.js | 122 ++++++++++++++++++++++++++++ frontend/loudness.test.js | 37 +++++++++ frontend/sw.js | 2 + frontend/wasm/loudness.c | 54 +++++++++++++ scripts/build-loudness-wasm.sh | 27 +++++++ server/db.js | 25 ++++++ server/server.js | 23 +++++- 10 files changed, 434 insertions(+), 4 deletions(-) create mode 100644 frontend/loudness-wasm.js create mode 100644 frontend/loudness.js create mode 100644 frontend/loudness.test.js create mode 100644 frontend/wasm/loudness.c create mode 100755 scripts/build-loudness-wasm.sh diff --git a/frontend/app.js b/frontend/app.js index 9ae5fef..23d55f9 100755 --- a/frontend/app.js +++ b/frontend/app.js @@ -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() { Loop the current video +