From 264bb26a3647e901139bb4ec03159c9e194af771 Mon Sep 17 00:00:00 2001 From: Jonathan Sykes Date: Sat, 3 Oct 2026 13:55:27 +0800 Subject: [PATCH] iPhone: EQ and loudness levelling for saved songs that keep playing when locked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iOS stops Web Audio when the screen locks, so live processing can't reach a locked phone. For saved songs, EqRender renders the same chain as the live EQ (5 bands -> level gain -> limiter) offline into a 32 kHz stereo WAV in OPFS (eq-renders/.wav, written by the OPFS worker's new write op), one song at a time in the background. Playback then takes the picture from the saved video (muted) and the sound from that copy in dual mode — plain media playback, so it continues with the screen locked; live gains are zeroed so nothing is applied twice. Copies are re-rendered when the EQ curve or a song's loudness changes and removed when nothing needs applying. - Level volume is now on by default everywhere (on iPhone it reaches saved songs through these copies). - New iPhone-only Settings -> Playback -> Live sound processing (off by default): live Web Audio EQ/levelling for streamed songs, stops when locked. - The EQ dialog works on iPhone and explains where the curve applies. --- frontend/app.js | 211 +++++++++++++++++++++++++++++++++++++--- frontend/opfs-worker.js | 22 +++++ 2 files changed, 217 insertions(+), 16 deletions(-) diff --git a/frontend/app.js b/frontend/app.js index 8681c7a..892e885 100755 --- a/frontend/app.js +++ b/frontend/app.js @@ -628,7 +628,8 @@ 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) + levelVolume: null, // loudness levelling; null = on (on iPhone: applied to saved songs' rendered copies) + liveAudioIOS: false, // iPhone: live Web Audio EQ/levelling for streamed songs (stops when the screen locks) parallelSaves: 4, // offline saves running at once (Settings → Offline cache) density: 'comfortable', // 'comfortable' | 'compact' perfMode: false, // disable heavy visual effects for speed @@ -655,7 +656,15 @@ let queueSource = ''; // label of what's playing ('queue','pl let playFullMode = false; // "play in full" session: ignore resume positions, // A-B markers, shuffle, loop and repeat — play the // list start-to-finish, then stop. -let current = null; // { meta, qualities, audioUrl, localUrl? } +let current = null; // { meta, qualities, audioUrl, localUrl?, eqAudioUrl? } +// iPhone: object URL of the playing saved song's rendered EQ/levelled sound. +let _currentEqUrl = null; +async function useEqRender(id) { + if (_currentEqUrl) { URL.revokeObjectURL(_currentEqUrl); _currentEqUrl = null; } + if (typeof EqRender === 'undefined' || !WEB) return null; + try { _currentEqUrl = await EqRender.urlFor(id); } catch { _currentEqUrl = null; } + return _currentEqUrl; +} let dragSource = -1; // index of card being dragged let listFilter = ''; let selectMode = false; @@ -1801,6 +1810,7 @@ async function preload(video, { quiet = false, mux = false } = {}) { SaveQueue.remove(id); } if (res && res.ok && res.cached) { + if (typeof EqRender !== 'undefined') EqRender.schedule(id); cachedIds.add(id); cacheMutations++; recordDeviceFile(id, res); @@ -2235,7 +2245,9 @@ const Player = { } } catch { /* fall through to streaming */ } if (localUrl) { - current = { meta: { ...videoObj }, qualities: [], audioUrl: null, localUrl }; + const eqAudioUrl = await useEqRender(videoObj.id); + if (seq !== this._loadSeq) return; + current = { meta: { ...videoObj }, qualities: [], audioUrl: null, localUrl, eqAudioUrl }; this.afterLoad(); this.fallbackQueue = []; this.fbIndex = 0; this.attach(null); @@ -2265,7 +2277,8 @@ const Player = { if (seq !== this._loadSeq) return; if (localUrl) { _currentBlobUrl = localUrl; - current = { meta: { ...videoObj }, qualities: [], audioUrl: null, localUrl }; + const eqAudioUrl = await useEqRender(videoObj.id); + current = { meta: { ...videoObj }, qualities: [], audioUrl: null, localUrl, eqAudioUrl }; this.afterLoad(); this.fallbackQueue = []; this.fbIndex = 0; this.attach(null); @@ -2454,13 +2467,33 @@ const Player = { V.pause(); A.pause(); this.stopDrift(); + if (current.localUrl && current.eqAudioUrl && !audioOnly) { + // iPhone: the saved video's picture (muted) + its rendered EQ/levelled + // sound, kept in sync like an adaptive stream. + this.mode = 'dual'; + this.master = V; + this.secondary = A; + V.muted = true; + V.src = current.localUrl; + A.src = current.eqAudioUrl; + els.art.classList.add('hidden'); + this.applyVolume(); + this.applySpeed(); + this.master.load(); + this.secondary.load(); + const onReadyEq = () => { this.master.removeEventListener('canplay', onReadyEq); this._startPlayback(); }; + this.master.addEventListener('canplay', onReadyEq); + this.startDrift(); + return; + } + if (current.localUrl) { // Cached single file (carries its own audio). Play it directly. if (audioOnly) { this.mode = 'audio'; this.master = A; this.secondary = null; - A.src = current.localUrl; + A.src = current.eqAudioUrl || current.localUrl; V.removeAttribute('src'); V.load(); els.art.classList.remove('hidden'); els.artImg.src = current.meta.thumbnail || ''; @@ -6380,6 +6413,7 @@ const Level = (() => { let seq = 0; const read = () => { try { return JSON.parse(localStorage.getItem(KEY) || '{}'); } catch { return {}; } }; const remember = (id, lufs) => { + if (typeof EqRender !== 'undefined') setTimeout(() => EqRender.schedule(id), 0); 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]); @@ -6389,7 +6423,7 @@ const Level = (() => { 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 + return true; // on by default; on iPhone it reaches saved songs via EqRender }; const gainFor = (lufs) => Math.max(-12, Math.min(9, Loudness.TARGET - lufs)); @@ -6460,6 +6494,128 @@ const Level = (() => { return { onTrack, enabled, apply, gainFor, remembered: (id) => read()[id] }; })(); +// ============================================================================ +// iPhone: EQ + levelling rendered into saved songs +// ============================================================================ +// iOS stops Web Audio when the screen locks, so live processing can't reach a +// locked phone. For songs saved on the device, the same chain as the live EQ +// (5 bands → level gain → limiter) is rendered offline into a WAV copy in OPFS +// (eq-renders/.wav, 32 kHz stereo). Playback then takes the picture from +// the saved video (muted) and the sound from that copy — plain media playback, +// so it keeps going when locked. One render at a time, in the background. +const EqRender = (() => { + const DIR = 'eq-renders', KEY = 'ytpEqRenders', RATE = 32000, MAX_SECONDS = 12 * 60; + const active = () => EQ.isIOS() && WEB && !!window.OPFS && window.OPFS.isSupported() && typeof OfflineAudioContext !== 'undefined'; + const readMap = () => { try { return JSON.parse(localStorage.getItem(KEY) || '{}'); } catch { return {}; } }; + const writeMap = (m) => { try { localStorage.setItem(KEY, JSON.stringify(m)); } catch { /* blocked */ } }; + const queue = new Set(); + let running = false; + + // What a song's copy should carry right now, or null when the original is fine. + function wanted(id) { + const g = EQ.gains(); + const lufs = Level.enabled() ? Level.remembered(id) : null; + const levelDb = lufs == null ? 0 : Math.round(Level.gainFor(lufs) * 2) / 2; + if (g.every((v) => v === 0) && Math.abs(levelDb) < 0.5) return null; + return { gains: g, levelDb, sig: g.join(',') + '|' + levelDb }; + } + async function dir() { return (await navigator.storage.getDirectory()).getDirectoryHandle(DIR, { create: true }); } + async function drop(id) { + try { await (await dir()).removeEntry(id + '.wav'); } catch { /* none */ } + const m = readMap(); if (m[id]) { delete m[id]; writeMap(m); } + } + + // Object URL of an up-to-date copy, or null (then the original plays as is). + async function urlFor(id) { + if (!active()) return null; + const w = wanted(id); + if (!w) return null; + if (readMap()[id] !== w.sig) { schedule(id); return null; } + try { return URL.createObjectURL(await (await (await dir()).getFileHandle(id + '.wav')).getFile()); } + catch { schedule(id); return null; } + } + + function wav(buffer) { + const ch = Math.min(2, buffer.numberOfChannels), n = buffer.length, rate = buffer.sampleRate; + const out = new ArrayBuffer(44 + n * ch * 2), v = new DataView(out); + const str = (o, t) => { for (let i = 0; i < t.length; i++) v.setUint8(o + i, t.charCodeAt(i)); }; + str(0, 'RIFF'); v.setUint32(4, 36 + n * ch * 2, true); str(8, 'WAVE'); str(12, 'fmt '); + v.setUint32(16, 16, true); v.setUint16(20, 1, true); v.setUint16(22, ch, true); v.setUint32(24, rate, true); + v.setUint32(28, rate * ch * 2, true); v.setUint16(32, ch * 2, true); v.setUint16(34, 16, true); + str(36, 'data'); v.setUint32(40, n * ch * 2, true); + const data = [buffer.getChannelData(0), buffer.getChannelData(ch > 1 ? 1 : 0)]; + let o = 44; + for (let i = 0; i < n; i++) for (let c = 0; c < ch; c++) { + const x = Math.max(-1, Math.min(1, data[c][i])); + v.setInt16(o, x < 0 ? x * 0x8000 : x * 0x7fff, true); o += 2; + } + return out; + } + + function writeViaWorker(name, buffer) { + return new Promise((resolve, reject) => { + const w = new Worker('/opfs-worker.js'); + w.onmessage = (e) => { w.terminate(); (e.data && e.data.type === 'written') ? resolve() : reject(new Error((e.data && e.data.error) || 'write failed')); }; + w.onerror = () => { w.terminate(); reject(new Error('worker failed')); }; + w.postMessage({ op: 'write', dir: DIR, name, buffer }, [buffer]); + }); + } + + async function render(id) { + if (!cachedIds.has(id)) { await drop(id); return; } + const w = wanted(id); + if (!w) { await drop(id); return; } + if (readMap()[id] === w.sig) return; + const file = await window.OPFS.getFileObject(id); + if (!file) return; + const buf = await file.arrayBuffer(); + const decoded = await new Promise((res, rej) => { + // Safari's callback form and the promise form both resolve here. + const p = new OfflineAudioContext(2, RATE, RATE).decodeAudioData(buf, res, rej); + if (p && p.then) p.then(res, rej); + }).catch(() => null); + if (!decoded || decoded.duration > MAX_SECONDS) return; + const off = new OfflineAudioContext(2, decoded.length, RATE); + const src = off.createBufferSource(); src.buffer = decoded; + let node = src; + EQ.bands.forEach((b, i) => { + const f = off.createBiquadFilter(); f.type = b.type; f.frequency.value = b.f; f.Q.value = 1.0; f.gain.value = w.gains[i]; + node.connect(f); node = f; + }); + const gain = off.createGain(); gain.gain.value = Math.pow(10, w.levelDb / 20); + const lim = off.createDynamicsCompressor(); + lim.threshold.value = -1; lim.knee.value = 0; lim.ratio.value = 20; lim.attack.value = 0.003; lim.release.value = 0.25; + node.connect(gain); gain.connect(lim); lim.connect(off.destination); + src.start(); + const out = await off.startRendering(); + if (wanted(id)?.sig !== w.sig) return; // settings changed meanwhile — the queue re-runs it + await writeViaWorker(id + '.wav', wav(out)); + const m = readMap(); m[id] = w.sig; writeMap(m); + } + + async function pump() { + if (running) return; + running = true; + try { + while (queue.size) { + const id = queue.values().next().value; + queue.delete(id); + try { await render(id); } catch { /* skip this one; it is retried next time it plays */ } + await new Promise((r) => setTimeout(r, 300)); + } + } finally { running = false; } + } + function schedule(id) { if (!active() || !id) return; queue.add(id); setTimeout(pump, 1500); } + // Every saved song (EQ changed); also prunes copies of songs no longer saved. + function scheduleAll() { + if (!active()) return; + cachedIds.forEach((id) => queue.add(id)); + Object.keys(readMap()).forEach((id) => { if (!cachedIds.has(id)) queue.add(id); }); + setTimeout(pump, 1500); + } + return { urlFor, schedule, scheduleAll, active, wav }; +})(); + // ============================================================================ // Picture-in-picture // ============================================================================ @@ -6531,7 +6687,12 @@ const EQ = (() => { const st = { ctx: null, filters: null, sources: new Map() }; // 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); + // Live Web Audio is off on iOS (it silences playback when the screen locks) + // unless the user opted into "Live sound processing". Saved songs on iPhone + // get EQ + levelling rendered into a copy instead (EqRender). + const available = () => !!AC && (!isIOS || data.settings.liveAudioIOS === true); + // A saved song playing its rendered copy already carries EQ and levelling. + const rendered = () => !!(current && current.eqAudioUrl); 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))); @@ -6579,7 +6740,7 @@ const EQ = (() => { if (!build()) return; attach(els.video); attach(els.audio); - const g = gains(); + const g = rendered() ? EQ_BANDS.map(() => 0) : gains(); st.filters.forEach((f, i) => { f.gain.value = g[i]; }); if (st.ctx.state === 'suspended') st.ctx.resume().catch(() => {}); renderButton(); @@ -6598,15 +6759,14 @@ const EQ = (() => { persist(); apply(); renderButton(); + if (typeof EqRender !== 'undefined') EqRender.scheduleAll(); } function open() { const body = document.createElement('div'); body.className = 'eq-panel'; - if (!available()) { - body.innerHTML = ``; + if (!AC) { + body.innerHTML = ''; showModal('🎚 Equalizer', body, [{ label: 'Close', primary: true, onClick: closeModal }]); return; } @@ -6621,7 +6781,9 @@ const EQ = (() => { ${b.label} `).join('')} -

Changes apply instantly. Hz bands from bass (left) to treble (right).

`; +

${isIOS && !available() + ? 'On iPhone the equalizer is applied to saved songs (a processed copy is made in the background, so they keep playing with the screen locked). For streamed songs, turn on Live sound processing in Settings → Playback.' + : 'Changes apply instantly. Hz bands from bass (left) to treble (right).'}

`; }; draw(); body.addEventListener('click', (e) => { @@ -6665,6 +6827,7 @@ const EQ = (() => { // Set the levelling gain (dB). 0 dB never wires the graph on its own. function setLevel(db) { if (!available()) return false; + if (rendered()) db = 0; if (!st.ctx && Math.abs(db) < 0.3) return true; if (!build()) return false; attach(els.video); @@ -6675,7 +6838,7 @@ const EQ = (() => { 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, + return { open, apply, renderButton, available, analyser, setLevel, gains, bands: EQ_BANDS, 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 }) }; })(); @@ -9285,11 +9448,18 @@ async function renderSettings() { Level volume between songs ${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). On iPhone it is built into saved songs, so they keep playing with the screen locked.' : 'Brings every song to the same loudness (−14 LUFS), measured on this device and shared.'} + ${EQ.isIOS() ? `` : ''}