iPhone: EQ and loudness levelling for saved songs that keep playing when locked

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/<id>.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.
This commit is contained in:
Jonathan Sykes
2026-10-03 13:55:27 +08:00
parent 2c6f285cfe
commit 264bb26a36
2 changed files with 217 additions and 16 deletions

View File

@@ -185,7 +185,29 @@ async function resumableSave(videoId, url, target) {
self.postMessage({ type: 'done', ext: target.ext || 'mp4', sha256, expectedSha, size: target.size });
}
// Write a whole file into an OPFS sub-directory (sync access handle: works on
// iOS Safari, where main-thread createWritable is missing). Used for the
// iPhone EQ/levelling renders (EqRender in app.js).
async function writeFile(dirName, name, buffer) {
const root = await navigator.storage.getDirectory();
const dir = await root.getDirectoryHandle(dirName, { create: true });
const h = await dir.getFileHandle(name, { create: true });
const a = await h.createSyncAccessHandle();
try {
const bytes = new Uint8Array(buffer);
a.truncate(0);
a.write(bytes, { at: 0 });
a.truncate(bytes.byteLength);
a.flush();
} finally { a.close(); }
}
self.onmessage = async (e) => {
if (e.data && e.data.op === 'write') {
try { await writeFile(e.data.dir, e.data.name, e.data.buffer); self.postMessage({ type: 'written' }); }
catch (err) { self.postMessage({ type: 'error', error: err && err.message ? err.message : String(err) }); }
return;
}
const { videoId, url, resumable } = e.data || {};
if (