Skip to content

Handoff real între sunete: cel vechi cântă până când cel nou chiar se aude#41

Closed
adyz wants to merge 8 commits into
masterfrom
fix/sound-handoff
Closed

Handoff real între sunete: cel vechi cântă până când cel nou chiar se aude#41
adyz wants to merge 8 commits into
masterfrom
fix/sound-handoff

Conversation

@adyz

@adyz adyz commented Jul 3, 2026

Copy link
Copy Markdown
Owner

Fix-ul pentru "din prima nu aud eroarea pe lock screen, dar dacă frec butoanele merge" (testat de Adrian pe preview).

Problema

play() pe un element audio doar inițiază pornirea (decodare/buffer, async). Reordonarea play-înainte-de-stop din PR-ul anterior nu garanta suprapunerea: sunetul vechi era oprit imediat după inițierea celui nou, deci rămânea un gol real de liniște. Pe iPhone blocat, în golul ăla moare sesiunea audio a aplicației și iOS refuză play()-ul în curs — de-aia loading-ul (pornit lângă gestul userului) se auzea, iar eroarea (pornită la ~9s de ultimul gest) nu.

Fix

audioInstance urmărește acum starea "pornire în curs" (până la evenimentul playing), iar stop()-ul sunetului vechi se amână până când înlocuitorul chiar produce audio:

  • loading → eroare (și invers): zero liniște — vechiul cântă până pornește noul
  • dacă iOS refuză pornirea noului sunet: vechiul continuă să cânte (invariantul "orice sunet > liniște") și supervizorul reîncearcă la 2.5s
  • stop/pauză de la user: liniște imediată pe ambele (oprirea unui sunet în curs de pornire eliberează și stop-ul amânat al partenerului)

Teste

  • ✅ typecheck, 63/63 unit, build, 37/37 e2e
  • O aserțiune e2e actualizată la noua semantică (schimbare deliberată de comportament): offline station change plays the error sound, not the loading one — verifica instantaneu că loading-ul e oprit; acum așteaptă finalizarea handoff-ului. Intenția testului (în final se aude doar eroarea) e neschimbată.
  • Testul decisiv e la tine: preview pe telefon → play → net tăiat → lock screen → eroarea ar trebui să se audă din prima, fără frecat de butoane.

🤖 Generated with Claude Code

…audible

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 <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 3, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
radio Ready Ready Preview, Comment Jul 3, 2026 1:40pm

@github-actions

github-actions Bot commented Jul 3, 2026

Copy link
Copy Markdown

CI Summary

Check Status Result
Typecheck PASS tsc --noEmit clean
Unit tests PASS 65/65 passed; Coverage: Lines 99.72%, Branches 84.48%, Functions 93.33%, Statements 99.72%
Build PASS Build completed
Playwright browser PASS Chromium installed
E2E tests PASS 38/38 passed

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 <noreply@anthropic.com>
@adyz

adyz commented Jul 3, 2026

Copy link
Copy Markdown
Owner Author

Escaladare după testul lui Adrian pe telefon (handoff-ul a eliminat liniștea — loading-ul continuă să cânte — dar eroarea tot nu pornea): iOS în background refuză orice pornire proaspătă de element audio, permite doar continuarea unuia care deja rulează (așa merg playlisturile în background). Noul commit adaugă exact asta ca ultimă soluție: dacă sunetul de eroare nu a reușit să pornească până la tick-ul supervizorului iar elementul de loading încă se aude, elementul de loading e împrumutat — i se schimbă sursa la tonul de eroare și continuă (continuare, nu pornire nouă). O singură tentativă per ciclu, cu revert dacă și continuarea e refuzată (nu schimbăm niciodată audibil pe mut). Dacă elementul de eroare pornește totuși între timp, împrumutul se eliberează automat prin handoff-ul existent. De retestat pe telefon: play → net tăiat → lock screen → ar trebui să auzi tonul de eroare (chiar dacă tehnic iese din elementul de loading).

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 <noreply@anthropic.com>
Setting mediaSession.metadata synchronously inside a media element
'play'/'playing'/'pause' handler gets clobbered by WebKit's internal
Now Playing refresh that runs right after the event — which blanked the
macOS widget even during loading. Defer the re-assert by 150ms (deduped
across bursts of element events) so it lands after WebKit's update.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two error-state-specific causes, on top of the deferred re-assert:

- The main <audio> element dies completely there (pause + src='') and
  its events trigger WebKit's own Now Playing refresh after our sfx-
  driven re-asserts had already run. Re-assert (deferred, deduped) from
  the main player's 'pause' and 'emptied' events too.
- The widget fetches artwork itself, bypassing our SW cache — offline,
  the failing Cloudinary fetch can take the whole entry with it. When
  navigator.onLine is false, set the metadata without artwork: a title
  without an image beats neither.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…the watchdog

Turning wifi off mid-playback left ~5-6s of silence: the buffer drains
fast but the watchdog needs 3 frozen ticks to declare the stream dead.
When the browser itself reports the network is gone there is nothing to
wait for — enter the audible retry/error pipeline immediately. The
watchdog remains the detector for wifi-without-internet, where no
'offline' event ever fires.

Two new unit tests (65 total); e2e untouched, 37/37.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adica and others added 2 commits July 3, 2026 16:29
Three attempts (deferred re-assert, main-player event hooks, offline
artwork removal) did not fix the blank widget in the offline error
state on macOS — an acceptable edge case (offline on a laptop is rare).
Keep the branch focused on what field-testing confirmed: the iOS
lock-screen sound handoff/takeover.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- One consolidated protocol comment atop the sfx section documenting the
  two device-verified iOS rules and the three mechanisms (deferred stop,
  carry, reclaim); hijackWith/borrowedSrc renamed to the calmer
  carrySound/carriedSrc; per-method docs on SfxInstance.
- New e2e (white-box exception, same rationale as the cache-versioning
  describe): patches the error element's play() to reject like
  backgrounded iOS, then asserts the audible outcome — the loading
  element keeps playing, carries the error tone (src switches away from
  its own sound), the denied element never starts, and recovery still
  silences everything once the network returns.

65/65 unit, 38/38 e2e, typecheck clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@adyz

adyz commented Jul 3, 2026

Copy link
Copy Markdown
Owner Author

Închis la decizia lui Adrian: mecanismul handoff/carry funcționa pe device, dar contrazice filozofia proiectului (state machine = precizie, fără sunete suprapuse, fără coordonare event-driven estimată). Cunoștințele câștigate rămân documentate: (1) iOS în background refuză orice pornire proaspătă de element audio; (2) permite unui element care deja cântă să-și schimbe src și să continue. Direcția agreată pentru o eventuală reluare: UN SINGUR element de feedback («canal») cu setTone dirijat declarativ de state machine — fără handoff, overlap imposibil prin construcție.

@adyz adyz closed this Jul 3, 2026
@adyz adyz deleted the fix/sound-handoff branch July 3, 2026 14:21
adyz added a commit that referenced this pull request Jul 4, 2026
…omes R4b, next after R4

Adrian's explicit requirement: the error sound must be audible on the
lock screen FROM THE FIRST TIME, not only after an error cycle with the
app open. The play-then-lock repro is a requirement bug, not an accepted
limitation. PR #41 proved the mechanism works on device; R4b rebuilds it
machine-driven, ordered before R5/R6.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant