From 572097607a13399cbefddec3d9572ed7bf27b6ce Mon Sep 17 00:00:00 2001 From: Adrian Florescu Date: Sat, 4 Jul 2026 09:24:59 +0300 Subject: [PATCH 1/3] =?UTF-8?q?R4b:=20the=20device-verified=20sound=20hand?= =?UTF-8?q?off=20=E2=80=94=20deferred=20stop,=20carry,=20gesture=20reconci?= =?UTF-8?q?le?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports PR #41's iPhone-verified protocol into the modular codebase, plus the new gesture-reconcile rule from Adrian's 2026-07-04 repro. The machine is untouched — all semantics live in soundEffects.ts behind the same play/stop/ensure contract, with the partner pairing wired in main.ts. Protocol (see the comment atop soundEffects.ts): - deferred stop: the OLD sound keeps playing until the NEW one actually produces audio ('playing' event) — no silent gap ever opens (the gap is where locked iOS kills the session and denies the pending start). - carry: if the new sound's start keeps being denied while the old one is audible, the old ELEMENT carries the new tone (src swap — the one start backgrounded iOS allows). One attempt per cycle; a denied carry restores the own sound — never trade audible for silent. - reclaim: play()/stop() on a carrying element restore its own sound. - gesture reconcile (NEW vs PR #41): warmUp() no longer early-returns on the intent flag — a desired-but-silent sound restarts INSIDE the user's gesture call stack. This fixes the wasted-gesture repro: unlock + next/ prev now revive the sound instead of requiring stop+play. Tests: - 11 new unit tests (handoff, carry, reclaim, gesture reconcile) on the fake-element harness; 85/85 unit total. - 2 new e2e in the white-box iOS-denial style: the carry outcome (ported from PR #41) and the historic all-silent state revived by a single next click. 39/39 e2e. - One existing e2e assertion adapted exactly as PR #41 documented: the instant loadingNoise.paused check becomes waiting for the handoff to complete — the test's intent (only the error sound ends up audible) is unchanged. NEEDS DEVICE VALIDATION (plan.md R4b acceptance criteria, all wifi-off): 1. play -> lock immediately -> error tone audible FROM THE FIRST TIME 2. from the silent state: unlock + next/prev revives the sound 3. stop + play still works 4. the normal flow (error heard with app open, then lock) stays intact Co-Authored-By: Claude Fable 5 --- e2e/radio.spec.js | 124 +++++++++++++- src/js/main.ts | 5 + src/js/soundEffects.test.ts | 331 ++++++++++++++++++++++++++++++------ src/js/soundEffects.ts | 170 ++++++++++++++++-- 4 files changed, 554 insertions(+), 76 deletions(-) diff --git a/e2e/radio.spec.js b/e2e/radio.spec.js index bc2493e..0accb6a 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 }) => { @@ -885,3 +887,121 @@ test.describe('Offline mid-playback — always audible', () => { await expect(c.loadingMsg).toBeHidden(); }); }); + +// Deliberate white-box exception (same rationale as the cache-versioning +// describe): the iOS behavior this guards — backgrounded Safari denying any +// FRESH play() start while allowing an already-playing element to swap its +// source and continue — cannot be reproduced in headless Chromium. We +// simulate the denial by patching the elements' play() to reject the way +// iOS does, then assert the user-audible outcome. +test.describe('iOS-like playback denial — sound carry', () => { + + test('when the error sound cannot start, the loading element carries it', async ({ page }) => { + test.setTimeout(60_000); + const c = ui(page); + + let connectionDown = false; + await page.route(STREAM_URL_RE, async (route) => { + if (connectionDown) { + await route.abort('internetdisconnected'); + return; + } + await route.fulfill({ status: 200, contentType: 'audio/mpeg', path: 'src/public/sounds/test-tone.mp3' }); + }); + + await page.goto('/'); + await waitForSoundBlobs(page); + await c.playButton.click(); + await expect(c.pauseButton).toBeVisible({ timeout: 8000 }); + + // From here on, the error element behaves like backgrounded iOS: every + // fresh start is denied. + await page.evaluate(() => { + const el = document.getElementById('errorNoise'); + el.play = () => Promise.reject(new DOMException('denied', 'NotAllowedError')); + }); + + connectionDown = true; + await page.context().setOffline(true); + + // The loading sound starts (retrying); remember its own source. + await expectSoundPlaying(page, 'loadingNoise'); + const loadingOwnSrc = await page.evaluate(() => document.getElementById('loadingNoise').src); + + await expect(c.errorMsg).toBeVisible({ timeout: 15000 }); + + // The user must NOT end up in silence: the loading element keeps playing + // and, once the supervisor notices the denied start, carries the error + // tone (its source switches away from its own sound). + await page.waitForFunction((ownSrc) => { + const carrier = document.getElementById('loadingNoise'); + return !carrier.paused && Boolean(carrier.src) && carrier.src !== ownSrc; + }, loadingOwnSrc, { timeout: 10000 }); + + // …while the denied element itself never started. + const errorPaused = await page.evaluate(() => document.getElementById('errorNoise').paused); + expect(errorPaused).toBe(true); + + // The network returns: the radio recovers and every feedback sound stops, + // including the carrying element. + connectionDown = false; + await page.context().setOffline(false); + await expect(c.pauseButton).toBeVisible({ timeout: 45000 }); + await page.waitForFunction(() => + document.getElementById('loadingNoise').paused && + document.getElementById('errorNoise').paused, + { timeout: 5000 }); + }); + + test('a user gesture revives the sound after total denial (unlock + next)', async ({ page }) => { + test.setTimeout(60_000); + const c = ui(page); + + let connectionDown = false; + await page.route(STREAM_URL_RE, async (route) => { + if (connectionDown) { + await route.abort('internetdisconnected'); + return; + } + await route.fulfill({ status: 200, contentType: 'audio/mpeg', path: 'src/public/sounds/test-tone.mp3' }); + }); + + await page.goto('/'); + await waitForSoundBlobs(page); + await c.playButton.click(); + await expect(c.pauseButton).toBeVisible({ timeout: 8000 }); + + // Locked iPhone with a dead audio session: EVERY fresh start on BOTH + // feedback elements is denied — no carry partner is audible, so the + // historic all-silent error state arises. + await page.evaluate(() => { + for (const id of ['loadingNoise', 'errorNoise']) { + const el = document.getElementById(id); + el._origPlay = el.play; + el.play = () => Promise.reject(new DOMException('denied', 'NotAllowedError')); + } + }); + + connectionDown = true; + await page.context().setOffline(true); + + await expect(c.errorMsg).toBeVisible({ timeout: 20000 }); + const bothSilent = await page.evaluate(() => + document.getElementById('loadingNoise').paused && + document.getElementById('errorNoise').paused); + expect(bothSilent).toBe(true); + + // The user unlocks the phone (play works again in a gesture context) + // and taps next. That single gesture must bring the sound back — + // the intent flag must not squander it. + await page.evaluate(() => { + for (const id of ['loadingNoise', 'errorNoise']) { + const el = document.getElementById(id); + el.play = el._origPlay; + } + }); + await c.nextButton.click(); + + await expectSoundPlaying(page, 'errorNoise'); + }); +}); diff --git a/src/js/main.ts b/src/js/main.ts index 3dd8436..83d59a4 100644 --- a/src/js/main.ts +++ b/src/js/main.ts @@ -46,6 +46,11 @@ initMediaSession({ hasRestoredStation }); const loadingNoiseInstance = audioInstance(loadingNoise); const errorNoiseInstance = audioInstance(errorNoise); +// Each sound hands off gracefully to the other: deferred stop until the +// replacement is audible, carry when iOS denies the replacement's start +// (see the protocol comment in soundEffects.ts). +loadingNoiseInstance.setPartner(errorNoiseInstance); +errorNoiseInstance.setPartner(loadingNoiseInstance); // Preload audio blobs once per page. Re-called from user interactions as a // retry if the eager page-load preload failed — fetch() doesn't need a user diff --git a/src/js/soundEffects.test.ts b/src/js/soundEffects.test.ts index 58ddb98..b4f70db 100644 --- a/src/js/soundEffects.test.ts +++ b/src/js/soundEffects.test.ts @@ -3,8 +3,12 @@ import { audioInstance } from './soundEffects'; /** * A fake HTMLAudioElement, just enough surface for audioInstance. - * Mirrors the real element's src semantics: the property assignment is - * reflected into the attribute, which is what ensure() inspects. + * Mirrors the real element's semantics where they matter: + * - the src property assignment is reflected into the attribute (what + * ensure()/warmUp() inspect); + * - a DENIED play() (iOS autoplay policy) leaves the element paused; + * - the 'playing' event (which settles a pending start) fires only when a + * test says the audio actually became audible, via emit('playing'). */ function fakeAudioElement() { const el = { @@ -12,18 +16,35 @@ function fakeAudioElement() { currentTime: 0, paused: true, playCalls: 0, + denied: false, playResult: Promise.resolve() as Promise, dataset: {} as Record, + listeners: {} as Record void>>, _srcAttr: null as string | null, set src(value: string) { this._srcAttr = value; }, get src(): string { return this._srcAttr ?? ''; }, getAttribute(name: string) { return name === 'src' ? this._srcAttr : null; }, + addEventListener(type: string, fn: () => void) { + (this.listeners[type] ??= []).push(fn); + }, + emit(type: string) { (this.listeners[type] ?? []).forEach(fn => fn()); }, play() { this.playCalls++; - this.paused = false; + if (!this.denied) this.paused = false; return this.playResult; }, pause() { this.paused = true; }, + /** Backgrounded-iOS mode: every play() is denied, element stays paused. */ + deny(name = 'NotAllowedError') { + this.denied = true; + const rejection = Promise.reject(Object.assign(new Error('denied'), { name })); + rejection.catch(() => {}); // pre-handled — audioInstance attaches its own catch later + this.playResult = rejection; + }, + allow() { + this.denied = false; + this.playResult = Promise.resolve(); + }, querySelector: () => ({ src: 'http://sounds.test/tone.mp3' }), }; return el; @@ -33,20 +54,22 @@ function flushPromises() { return new Promise(resolve => setTimeout(resolve, 0)); } -describe('audioInstance ensure()', () => { +describe('audioInstance', () => { let el: ReturnType; let resolveFetch: (r: Response) => void; + let blobCounter: number; beforeEach(() => { el = fakeAudioElement(); - // No Cache API, and a fetch we control — the blob preload stays pending - // until the test resolves it. + blobCounter = 0; + // No Cache API, and a fetch we control — each blob preload stays pending + // until the test resolves it. Every created blob URL is distinct. vi.stubGlobal('window', {}); vi.stubGlobal('fetch', vi.fn(() => new Promise((resolve) => { resolveFetch = resolve; }))); vi.stubGlobal('URL', { - createObjectURL: vi.fn(() => 'blob:fake'), + createObjectURL: vi.fn(() => `blob:fake-${++blobCounter}`), revokeObjectURL: vi.fn(), }); }); @@ -55,69 +78,265 @@ describe('audioInstance ensure()', () => { vi.unstubAllGlobals(); }); - it('does not poke the element while the initial play() still waits for the blob', async () => { - const sound = audioInstance(el as unknown as HTMLAudioElement); + /** play() + resolve the blob fetch, so the element actually started. */ + async function playWithBlob(sound: ReturnType) { + sound.play(); + await flushPromises(); + resolveFetch(new Response(new Blob(['x']))); + await flushPromises(); + } + + describe('ensure()', () => { + it('does not poke the element while the initial play() still waits for the blob', async () => { + const sound = audioInstance(el as unknown as HTMLAudioElement); - sound.play(); // blob preload pending — nothing started yet - await flushPromises(); // let the preload reach the (unresolved) fetch - expect(el.playCalls).toBe(0); + sound.play(); // blob preload pending — nothing started yet + await flushPromises(); // let the preload reach the (unresolved) fetch + expect(el.playCalls).toBe(0); - // Supervisor tick lands mid-preload. The old bug: ensure() called - // play() on the empty src, the rejection flipped isPlaying to false, - // and the pending start bailed — one extra tick of silence. - sound.ensure(); - expect(el.playCalls).toBe(0); + // Supervisor tick lands mid-preload. The old bug: ensure() called + // play() on the empty src, the rejection flipped isPlaying to false, + // and the pending start bailed — one extra tick of silence. + sound.ensure(); + expect(el.playCalls).toBe(0); - // When the blob finally lands, the original play() must still fire. - resolveFetch(new Response(new Blob(['x']))); - await flushPromises(); - expect(el.playCalls).toBe(1); - expect(el.getAttribute('src')).toBe('blob:fake'); + // When the blob finally lands, the original play() must still fire. + resolveFetch(new Response(new Blob(['x']))); + await flushPromises(); + expect(el.playCalls).toBe(1); + expect(el.getAttribute('src')).toBe('blob:fake-1'); + }); + + it('still restarts a started element the OS paused', async () => { + const sound = audioInstance(el as unknown as HTMLAudioElement); + + await playWithBlob(sound); + expect(el.playCalls).toBe(1); + + el.paused = true; // backgrounded: the OS paused it + sound.ensure(); + expect(el.playCalls).toBe(2); + }); + + it('restarts from scratch when a play() was rejected outright', async () => { + const sound = audioInstance(el as unknown as HTMLAudioElement); + + sound.play(); + await flushPromises(); + el.deny(); + resolveFetch(new Response(new Blob(['x']))); + await flushPromises(); // startPlayback ran, its play() was denied + expect(el.playCalls).toBe(1); + + el.allow(); + sound.ensure(); // isPlaying flipped false → full play() again + await flushPromises(); + expect(el.playCalls).toBe(2); + }); }); - it('still restarts a started element the OS paused', async () => { - const sound = audioInstance(el as unknown as HTMLAudioElement); + describe('deferred stop (handoff)', () => { + it('keeps the old sound playing until the replacement is audible', async () => { + const oldEl = el; + const newEl = fakeAudioElement(); + const oldSound = audioInstance(oldEl as unknown as HTMLAudioElement); + const newSound = audioInstance(newEl as unknown as HTMLAudioElement); + oldSound.setPartner(newSound); + newSound.setPartner(oldSound); - sound.play(); - await flushPromises(); - resolveFetch(new Response(new Blob(['x']))); - await flushPromises(); - expect(el.playCalls).toBe(1); + await playWithBlob(oldSound); + oldEl.emit('playing'); // old sound is audible + expect(oldSound.isAudiblyPlaying()).toBe(true); + + // The machine's applyFx order: the NEW sound starts BEFORE the old + // one stops. The new start is pending (its blob is still loading). + newSound.play(); + expect(newSound.isStartPending()).toBe(true); + oldSound.stop(); + + // Old element must still be playing — no silent gap. + expect(oldEl.paused).toBe(false); + + // The replacement becomes audible → the deferred stop completes. + newEl.emit('playing'); + expect(oldEl.paused).toBe(true); + expect(oldEl.getAttribute('src')).toBe(''); + }); + + it('a user stop silences both immediately (settling the pending start)', async () => { + const oldEl = el; + const newEl = fakeAudioElement(); + const oldSound = audioInstance(oldEl as unknown as HTMLAudioElement); + const newSound = audioInstance(newEl as unknown as HTMLAudioElement); + oldSound.setPartner(newSound); + newSound.setPartner(oldSound); - el.paused = true; // backgrounded: the OS paused it - sound.ensure(); - expect(el.playCalls).toBe(2); + await playWithBlob(oldSound); + oldEl.emit('playing'); + newSound.play(); // pending, never becomes audible + oldSound.stop(); // deferred, waiting on newSound + expect(oldEl.paused).toBe(false); + + // applyFx for idle/paused stops BOTH: stopping the pending sound + // settles it, which releases the partner's deferred stop too. + newSound.stop(); + expect(oldEl.paused).toBe(true); + expect(newEl.paused).toBe(true); + }); + + it('play-stop flapping does not fire a stale deferred stop', async () => { + const elA = el; + const elB = fakeAudioElement(); + const soundA = audioInstance(elA as unknown as HTMLAudioElement); + const soundB = audioInstance(elB as unknown as HTMLAudioElement); + soundA.setPartner(soundB); + soundB.setPartner(soundA); + + await playWithBlob(soundA); + elA.emit('playing'); + + soundB.play(); // pending + soundA.stop(); // deferred on B + soundA.play(); // fresh intent — cancels the deferral + elB.emit('playing'); // B settles now + + // The stale deferred stop must NOT kill A's fresh play cycle. + expect(elA.paused).toBe(false); + }); }); - it('restarts from scratch when a play() was rejected outright', async () => { - const sound = audioInstance(el as unknown as HTMLAudioElement); + describe('carry (iOS denies the fresh start)', () => { + // Loading is audible; the error sound's own start is denied like + // backgrounded iOS; loading's stop is deferred (error never audible). + async function carryScenario() { + const loadEl = el; + const errEl = fakeAudioElement(); + const loading = audioInstance(loadEl as unknown as HTMLAudioElement); + const error = audioInstance(errEl as unknown as HTMLAudioElement); + loading.setPartner(error); + error.setPartner(loading); - sound.play(); - await flushPromises(); - const denied = Promise.reject(Object.assign(new Error('denied'), { name: 'NotAllowedError' })); - denied.catch(() => {}); // pre-handle: audioInstance attaches its own catch later - el.playResult = denied; - resolveFetch(new Response(new Blob(['x']))); - await flushPromises(); // startPlayback ran, its play() was denied - expect(el.playCalls).toBe(1); + await playWithBlob(loading); // loading's blob = blob:fake-1 + loadEl.emit('playing'); - el.playResult = Promise.resolve(); - sound.ensure(); // isPlaying flipped false → full play() again - await flushPromises(); - expect(el.playCalls).toBe(2); + errEl.deny(); + error.play(); + await flushPromises(); + resolveFetch(new Response(new Blob(['y']))); // error's blob = blob:fake-2 + await flushPromises(); // startPlayback ran → denied, still paused + loading.stop(); // deferred: error never got audible + expect(loadEl.paused).toBe(false); + + return { loadEl, errEl, loading, error }; + } + + it('the audible partner carries the denied sound (src swap, keeps playing)', async () => { + const { loadEl, errEl, error } = await carryScenario(); + expect(loadEl.getAttribute('src')).toBe('blob:fake-1'); + + // Supervisor tick on the denied sound → escalate to carry. + error.ensure(); + await flushPromises(); + + expect(loadEl.getAttribute('src')).toBe('blob:fake-2'); // carries the error tone + expect(loadEl.paused).toBe(false); // and keeps playing + expect(errEl.paused).toBe(true); // denied element never started + }); + + it('carry is attempted once per denied cycle (no src thrash)', async () => { + const { loadEl, error } = await carryScenario(); + + error.ensure(); + await flushPromises(); + const callsAfterFirst = loadEl.playCalls; + + error.ensure(); // next tick: already carrying — no re-hijack + await flushPromises(); + expect(loadEl.playCalls).toBe(callsAfterFirst); + expect(loadEl.getAttribute('src')).toBe('blob:fake-2'); + }); + + it('never trades audible for silent: a denied carry restores the own sound', async () => { + const { loadEl, error } = await carryScenario(); + + loadEl.deny(); // even the continuation is denied + error.ensure(); + await flushPromises(); + + // src is restored to the carrier's own sound and play retried. + expect(loadEl.getAttribute('src')).toBe('blob:fake-1'); + }); + + it('reclaim: a fresh play() on the carrier restores its own sound', async () => { + const { loadEl, loading, error } = await carryScenario(); + + error.ensure(); // carry in effect + await flushPromises(); + expect(loadEl.getAttribute('src')).toBe('blob:fake-2'); + + loading.play(); // fresh intent (e.g. back to loading state) + await flushPromises(); + expect(loadEl.getAttribute('src')).toBe('blob:fake-1'); + expect(loadEl.paused).toBe(false); + }); }); - it('stop() detaches the element so ensure() has nothing to resurrect', async () => { - const sound = audioInstance(el as unknown as HTMLAudioElement); + describe('gesture reconcile (warmUp on a live sound)', () => { + it('restarts a desired-but-silent sound inside the gesture', async () => { + const sound = audioInstance(el as unknown as HTMLAudioElement); - sound.play(); - await flushPromises(); - resolveFetch(new Response(new Blob(['x']))); - await flushPromises(); - expect(el.playCalls).toBe(1); + sound.play(); + await flushPromises(); + el.deny(); + resolveFetch(new Response(new Blob(['x']))); + await flushPromises(); // start denied → element silent + + // The old bug: warmUp() returned early on the intent flag and the + // user's tap was squandered. Now it re-asserts playback right here. + el.allow(); + sound.warmUp(); + expect(el.playCalls).toBe(2); + expect(el.paused).toBe(false); + }); + + it('leaves an audibly playing sound alone', async () => { + const sound = audioInstance(el as unknown as HTMLAudioElement); + + await playWithBlob(sound); + el.emit('playing'); + const calls = el.playCalls; + + sound.warmUp(); + expect(el.playCalls).toBe(calls); // no restart glitch + }); + + it('still does the classic bless dance on an idle element', async () => { + const sound = audioInstance(el as unknown as HTMLAudioElement); + + // First warmUp only kicks off the preload (no blob yet). + sound.warmUp(); + await flushPromises(); + resolveFetch(new Response(new Blob(['x']))); + await flushPromises(); - sound.stop(); - expect(el.paused).toBe(true); - expect(el.getAttribute('src')).toBe(''); + sound.warmUp(); // blob ready, not playing → bless + await flushPromises(); + expect(el.playCalls).toBe(1); + expect(el.paused).toBe(true); // played then paused back + expect(el.currentTime).toBe(0); + }); + }); + + describe('stop()', () => { + it('detaches the element so ensure() has nothing to resurrect', async () => { + const sound = audioInstance(el as unknown as HTMLAudioElement); + + await playWithBlob(sound); + expect(el.playCalls).toBe(1); + + sound.stop(); + expect(el.paused).toBe(true); + expect(el.getAttribute('src')).toBe(''); + }); }); }); diff --git a/src/js/soundEffects.ts b/src/js/soundEffects.ts index 718b615..199b54b 100644 --- a/src/js/soundEffects.ts +++ b/src/js/soundEffects.ts @@ -5,6 +5,36 @@ * because iOS drops the media session without an active