From a0aec46130c83093e3b16d1d31dfd16567e90c4e Mon Sep 17 00:00:00 2001 From: Adica Date: Fri, 3 Jul 2026 15:42:01 +0300 Subject: [PATCH 1/8] Real sound handoff: the old sound keeps playing until the new one is audible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit play() only INITIATES playback (async decode/buffer) — the previous play-before-stop ordering still left a real silence gap, and on a locked iPhone that gap kills the audio session and gets the pending play() denied (the reported "loading is audible but the error sound never starts on the lock screen, until I fiddle with the buttons"). audioInstance now tracks a pending start ('playing' event settles it) and stop() defers the actual stop while the partner sound is still starting. If iOS keeps denying the new sound, the old one keeps playing (any sound beats silence) and the supervisor keeps retrying. A user stop/pause still silences both immediately: stopping a pending sound releases its partner's deferred stop. The 'offline station change' e2e assertion moves from an instant loadingNoise.paused check to waiting for the handoff to complete — deliberate behavior change, the test's intent (only the error sound ends up audible) is unchanged. 37/37 e2e, 63/63 unit, typecheck clean. Co-Authored-By: Claude Fable 5 --- e2e/radio.spec.js | 6 ++-- plan.md | 7 +++++ src/js/script.ts | 70 +++++++++++++++++++++++++++++++++++++++++++---- 3 files changed, 76 insertions(+), 7 deletions(-) diff --git a/e2e/radio.spec.js b/e2e/radio.spec.js index bc2493e..32b27fa 100644 --- a/e2e/radio.spec.js +++ b/e2e/radio.spec.js @@ -680,8 +680,10 @@ test.describe('Offline — cached resources', () => { await expect(c.errorMsg).toBeVisible({ timeout: 3000 }); await expectSoundPlaying(page, 'errorNoise'); - const loadingPaused = await page.evaluate(() => document.getElementById('loadingNoise').paused); - expect(loadingPaused).toBe(true); + // The loading sound hands off gracefully: it may keep playing for the few + // ms until the error sound actually produces audio (iOS session handoff), + // but once the error sound is on, the loading one must fall silent. + await page.waitForFunction(() => document.getElementById('loadingNoise').paused, { timeout: 3000 }); }); test('error sound plays while offline, with no sound network request', async ({ page }) => { diff --git a/plan.md b/plan.md index 2b85ba7..7f3d252 100644 --- a/plan.md +++ b/plan.md @@ -127,6 +127,13 @@ dat pauza. Cauze gasite si fixate (branch `fix/always-audible-offline`): suprapunere) — golul de tacere dintre stop si play era exact locul unde iOS refuza play() in background/lock screen. (banuit vinovat pentru "eroarea nu se aude pe lock screen desi loading da") +6. Handoff real intre sunete (branch `fix/sound-handoff`): play() doar + INITIAZA pornirea (async) — punctul 5 nu garanta suprapunerea. Acum + stop()-ul sunetului vechi se amana pana cand cel nou emite efectiv + 'playing'; daca iOS refuza pornirea, cel vechi continua sa cante + (invariantul: orice sunet > liniste) iar supervizorul reincearca. + Testul e2e 'offline station change...' actualizat la noua semantica + (asteapta finalizarea handoff-ului, nu opririle instantanee). Teste: 63 unit (incl. 'error sound stays audible indefinitely' si testul de ordine play-inainte-de-stop), 37 e2e — cele 35 vechi neatinse si verzi. diff --git a/src/js/script.ts b/src/js/script.ts index ad79156..14c77c4 100644 --- a/src/js/script.ts +++ b/src/js/script.ts @@ -135,7 +135,18 @@ async function getSoundResponse(src: string): Promise { return response; } -function audioInstance(htmlElement: HTMLAudioElement) { +interface SfxInstance { + play(): void; + stop(): void; + ensure(): void; + warmUp(): void; + preloadBlob(): Promise; + isStartPending(): boolean; + onStartSettled(callback: () => void): void; + setPartner(partner: SfxInstance): void; +} + +function audioInstance(htmlElement: HTMLAudioElement): SfxInstance { let initialSrc = htmlElement.querySelector('source')!.src; let isPlaying = false; let blobUrl: string | null = null; @@ -143,6 +154,22 @@ function audioInstance(htmlElement: HTMLAudioElement) { let preloadPromise: Promise | null = null; htmlElement.dataset.blobReady = 'false'; + // --- Graceful handoff state --- + // iOS kills the app's audio session in any gap of silence and then denies + // the next play(). When this sound replaces its partner (loading <-> error), + // the partner keeps playing until THIS element actually produces audio + // ('playing' event) — only then does the partner's deferred stop run. + let partner: SfxInstance | null = null; + let startPending = false; + let stopDeferGeneration = 0; + const startSettlers: Array<() => void> = []; + + const settleStart = () => { + startPending = false; + while (startSettlers.length) startSettlers.shift()!(); + }; + htmlElement.addEventListener('playing', settleStart); + const preloadBlob = () => { if (blobUrl) return Promise.resolve(blobUrl); if (preloadPromise) return preloadPromise; @@ -179,7 +206,11 @@ function audioInstance(htmlElement: HTMLAudioElement) { }; const play = () => { + // Any play intent cancels a deferred stop queued for this instance + // (e.g. error → loading → error flapping while the partner never started). + stopDeferGeneration++; if (!isPlaying) { + startPending = true; const gen = ++playGeneration; isPlaying = true; if (blobUrl) { @@ -190,13 +221,32 @@ function audioInstance(htmlElement: HTMLAudioElement) { } }; + const doStop = () => { + playGeneration++; + htmlElement.pause(); + htmlElement.src = ''; + isPlaying = false; + }; + return { play, stop() { - playGeneration++; - htmlElement.pause(); - htmlElement.src = ''; - isPlaying = false; + // This sound will never start now — release anyone waiting on it + // (the partner's own deferred stop), so a user stop silences BOTH. + startPending = false; + settleStart(); + + // Handoff: if the replacement sound was asked to play but hasn't + // produced audio yet, keep this one playing until it does — a gap of + // silence here is where iOS would deny the replacement's play(). + if (partner?.isStartPending()) { + const deferGen = ++stopDeferGeneration; + partner.onStartSettled(() => { + if (deferGen === stopDeferGeneration) doStop(); + }); + return; + } + doStop(); }, // Self-healing: called periodically by the core's sound supervisor while // this sound is supposed to be audible. Restarts playback if a play() @@ -231,11 +281,21 @@ function audioInstance(htmlElement: HTMLAudioElement) { }).catch(() => {}); }, preloadBlob, + isStartPending: () => startPending, + onStartSettled(callback: () => void) { + startSettlers.push(callback); + }, + setPartner(p: SfxInstance) { + partner = p; + }, }; } const loadingNoiseInstance = audioInstance(loadingNoise); const errorNoiseInstance = audioInstance(errorNoise); +// Each sound hands off gracefully to the other (see audioInstance.stop()). +loadingNoiseInstance.setPartner(errorNoiseInstance); +errorNoiseInstance.setPartner(loadingNoiseInstance); // Eagerly preload sound blobs so loading/error feedback can start from memory. // fetch() doesn't need a user gesture — only playback does. From 31d684a2c5ee8f23f7f67d2691e1c8de19e6abf6 Mon Sep 17 00:00:00 2001 From: Adica Date: Fri, 3 Jul 2026 15:49:17 +0300 Subject: [PATCH 2/8] iOS last resort: borrow the already-playing element for the error sound MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Field testing showed backgrounded iOS denies FRESH playback starts outright: with the handoff in place the loading sound now keeps playing (no more silence), but the error element still never starts. iOS does allow an already-playing element to swap sources and continue (playlist-style) — so when a sound has not managed to start by a supervisor tick while its partner is audibly playing, it hijacks the partner element: swaps its src to the right tone and lets it continue. - One takeover attempt per play cycle; reverts to the partner's own sound if even the continuation is denied (never trade audible for silent). - play()/stop() reclaim/reset a borrowed element automatically; if the rightful element later starts, the borrowed one is released by the existing deferred-stop handoff. Typecheck clean, 63/63 unit, 37/37 e2e (untouched this time). Co-Authored-By: Claude Fable 5 --- src/js/script.ts | 44 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/src/js/script.ts b/src/js/script.ts index 14c77c4..ec830ea 100644 --- a/src/js/script.ts +++ b/src/js/script.ts @@ -144,6 +144,11 @@ interface SfxInstance { isStartPending(): boolean; onStartSettled(callback: () => void): void; setPartner(partner: SfxInstance): void; + isAudiblyPlaying(): boolean; + /** Last-resort iOS takeover: swap this (already playing) element's source + * to the partner's sound — a continuation, which backgrounded iOS allows, + * unlike the fresh start it just denied. */ + hijackWith(src: string): void; } function audioInstance(htmlElement: HTMLAudioElement): SfxInstance { @@ -162,6 +167,8 @@ function audioInstance(htmlElement: HTMLAudioElement): SfxInstance { let partner: SfxInstance | null = null; let startPending = false; let stopDeferGeneration = 0; + let borrowedSrc: string | null = null; // set while this element carries the partner's sound + let hijackAttempted = false; // one takeover attempt per play cycle const startSettlers: Array<() => void> = []; const settleStart = () => { @@ -209,6 +216,12 @@ function audioInstance(htmlElement: HTMLAudioElement): SfxInstance { // Any play intent cancels a deferred stop queued for this instance // (e.g. error → loading → error flapping while the partner never started). stopDeferGeneration++; + hijackAttempted = false; + if (borrowedSrc) { + // The element is carrying the partner's sound — reclaim it with our own. + borrowedSrc = null; + isPlaying = false; + } if (!isPlaying) { startPending = true; const gen = ++playGeneration; @@ -226,6 +239,8 @@ function audioInstance(htmlElement: HTMLAudioElement): SfxInstance { htmlElement.pause(); htmlElement.src = ''; isPlaying = false; + borrowedSrc = null; + hijackAttempted = false; }; return { @@ -254,15 +269,22 @@ function audioInstance(htmlElement: HTMLAudioElement): SfxInstance { ensure() { if (!isPlaying) { play(); - return; - } - if (htmlElement.paused) { + } else if (htmlElement.paused) { const gen = playGeneration; htmlElement.play().catch((error) => { if (gen !== playGeneration) return; if (error.name !== 'AbortError') isPlaying = false; // retried next tick }); } + + // iOS last resort: we still haven't produced audio (backgrounded iOS + // denies FRESH playback starts), but the partner element is audibly + // playing — and a playing element may swap sources and continue + // (playlist-style). Borrow it so the right sound is heard. + if (startPending && htmlElement.paused && !hijackAttempted && partner?.isAudiblyPlaying()) { + hijackAttempted = true; + partner.hijackWith(blobUrl || initialSrc); + } }, warmUp() { if (isPlaying) return; @@ -288,6 +310,22 @@ function audioInstance(htmlElement: HTMLAudioElement): SfxInstance { setPartner(p: SfxInstance) { partner = p; }, + isAudiblyPlaying: () => isPlaying && !htmlElement.paused, + hijackWith(src: string) { + if (!isPlaying || htmlElement.paused) return; // nothing audible to borrow + if (borrowedSrc === src) return; // already carrying it + borrowedSrc = src; + htmlElement.src = src; + htmlElement.currentTime = 0; + htmlElement.play().catch(() => { + // The continuation was denied too — restore our own sound rather + // than end up fully silent. + borrowedSrc = null; + htmlElement.src = blobUrl || initialSrc; + htmlElement.currentTime = 0; + htmlElement.play().catch(() => {}); + }); + }, }; } From e4c69415f399439fbced89ca4cbb8ba9bbd455d7 Mon Sep 17 00:00:00 2001 From: Adica Date: Fri, 3 Jul 2026 15:55:15 +0300 Subject: [PATCH 3/8] Re-assert MediaSession metadata when the active audio element changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit macOS ties the Now Playing widget to the tab's active media element and drops the metadata when a different one takes over. With the sound handoff/hijack the element switch now happens AFTER the state transition (the old sound stops only once the new one is audible), so the metadata set by updateMediaSession got detached — the widget went blank in the error state while it still worked during loading. Keep the last MediaMetadataInit and re-set it wherever we already re-assert handlers/playbackState on element switches ('play'/'playing' and 'pause' listeners of both feedback sounds). Co-Authored-By: Claude Fable 5 --- src/js/script.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/js/script.ts b/src/js/script.ts index ec830ea..b91ba70 100644 --- a/src/js/script.ts +++ b/src/js/script.ts @@ -365,10 +365,22 @@ function registerMediaSessionHandlers() { // the lock-screen shows prev/next instead of skip ±10 s. // Also force playbackState='playing' so macOS doesn't briefly show "Not Playing" // in the gap between pausing the main player and the sound effect producing audio. +// The last metadata we set — re-asserted whenever the active