Fix Morse trainer audio on mobile Safari (await AudioContext resume)#2088
Merged
Conversation
iOS Safari starts the Web Audio context suspended and resume() is async; the trainer fired resume() without awaiting it, so the first tones were scheduled against a still-suspended clock and never sounded on mobile Safari. ensureCtx now awaits resume() before returning (matching metronome.js / scorePlayback.js / songPlayback.js), and the keying decoder's startTone awaits it too (bailing on a fast release to avoid an orphan oscillator) while stopTone reads the clock off the live oscillator's context so it stays synchronous. Claude-Session: https://claude.ai/code/session_01USGJgLzYBHwis8rn38K3Pn
…phan tone - Regression test now resolves the mock resume() on a macrotask so the test actually fails if the await is removed (a microtask-resolving mock passed either way, giving false confidence). - startTone uses a generation token, not just pressingRef, so a release-then-repress during first-press resume latency can't leave two overlapping starts both creating an oscillator and orphaning the first. Claude-Session: https://claude.ai/code/session_01USGJgLzYBHwis8rn38K3Pn
Moving the resume() await earlier means prompt/playing aren't set until after the first-play iOS unlock, so the Start Round / New Round button stays live during that window. A second tap started a second overlapping playPrompt(true), scheduling two Morse prompts over each other with the UI tracking only the last. Add a synchronous playingRef re-entrancy guard to startRound/playPrompt, with a regression test asserting a double-tap in the unlock window creates only one oscillator. Claude-Session: https://claude.ai/code/session_01USGJgLzYBHwis8rn38K3Pn
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The Morse code trainer produced no sound on mobile Safari. iOS Safari starts the Web Audio
AudioContextsuspended andresume()is asynchronous — the trainer firedresume()without awaiting it, so the first tones were scheduled against a still-suspended clock and never sounded. Desktop browsers resume fast enough that the 50 ms scheduling lead hid the bug.Root cause
useAudioContext().ensureCtx()calledctx.resume()fire-and-forget, unlike every other audio module in the app (metronome.js,scorePlayback.js,songPlayback.js), which allawait c.resume()before scheduling.Changes
ensureCtxnowawaitsresume()before returning the context (Copy / Head Copy / Send all benefit).playPromptawaitsensureCtx().startToneawaitsensureCtx()and uses a generation token so a fast release-then-repress during the first-play unlock can't orphan a droning oscillator;stopTonereads the clock off the live oscillator'scontextso it stays synchronous.startRound/playPrompt: the earlier await widened the window where the Start Round button is still live during the iOS unlock, so a double-tap could schedule two overlapping prompts — now ignored.Tests
Added regression tests in
MorseTrainer.test.jsx:suspended(mock resolvesresume()on a macrotask so the test actually fails if theawaitregresses).All 18 MorseTrainer tests + 80 meatspace tests pass. Reviewed locally with claude + codex.
https://claude.ai/code/session_01USGJgLzYBHwis8rn38K3Pn