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 ----