Findings No. 1
iOS starts an AudioContext suspended, and the promise from resume() may never settle
Schedule nothing until the context state reads running: poll state alongside onstatechange, give the wake a deadline, and replace a context that never wakes.
On iOS, an AudioContext is born suspended, wakes slowly, and the promise returned by resume() is not a reliable signal that it ever woke: one test phone sat on a loading state for minutes while awaiting it. Meanwhile, notes scheduled against currentTime pile up at time zero, so every key pressed during the wake sounds at once, a second or two later.
The shape that works: warm the context on the first touch, hold notes until ctx.state reads running, and watch the wake by polling state alongside the onstatechange event rather than trusting the promise. Play a silent one-sample buffer as the old-style unlock. Give the wake a deadline (three seconds in our piano), and if the context never comes up, throw it away so the next touch gets a fresh one.
Receipt: Stem Shovel's piano (piano.svelte.ts, commits 58232b7, 37067c1, a410a8d; docs/piano.md, "iOS and the first note").