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
+