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 +