Summary
On iOS Safari, a touch that ends as a swipe grants the page no user activation; only a tap does. play() checks navigator.userActivation.hasBeenActive and returns, so a first cue fired from a swipe never creates the AudioContext. A later tap on empty space, or on a control with no cue of its own, still leaves the next swipe creating the context off the gesture's call stack, suspended, so it takes a second tap that carries a cue before anything is heard.
The pending-resume half of this also hits the README's own async example, await navigator.clipboard.writeText(); play("success"), when it is the first cue of a visit: the continuation after await is off the click's call stack, WebKit leaves resume() pending, and every later play() inherits the silence.
Trace from an iPhone
A dev spy wrapped window.AudioContext and logged the window events. The second column is navigator.userActivation.hasBeenActive (- false, A true). A swipe deck built on a pointer-event drag library; the deck element has touch-action: none, so swipes end in pointerup, never pointercancel.
2419 - ev pointerdown
2420 - ev touchstart
2499 - ev pointerup
2499 - ev touchend
... six more swipes, all "-", no context created ...
7263 - ev pointerdown
7264 - ev touchstart
7319 A ev pointerup <- the first tap
7320 A ev touchend
7325 A ev click
7326 A ctx created state=suspended on=click
7327 A resume#1 state=suspended on=click
7343 A resume#1 settled state=running
7343 A render osc state=running t=0.000
8333 A ev pointerup <- swipes now render directly
8335 A render noise state=running t=0.955
Eight swipes, all without activation and without a context. The first tap flips the flag, creates the context inside the click, and resume() settles 16 ms later. Desktop Chrome is unaffected: it honours sticky activation.
Proposed fix
- A
play() the browser blocks, before activation or off a gesture's stack, arms one set of passive capture listeners on window for touchend, click, keydown and mousedown. The first gesture that counts creates the shared context if needed and calls resume() on that stack. The listeners leave once the context runs.
- A cue waiting on a pending
resume() renders if it was asked for within the last second, otherwise it is dropped, so a backlog cannot burst out on a later tap.
- An explicit
prime() for apps that want to start the context from a gesture of their own.
Independent of #14 / #15, which address the cold Chrome renderer.
Summary
On iOS Safari, a touch that ends as a swipe grants the page no user activation; only a tap does.
play()checksnavigator.userActivation.hasBeenActiveand returns, so a first cue fired from a swipe never creates theAudioContext. A later tap on empty space, or on a control with no cue of its own, still leaves the next swipe creating the context off the gesture's call stack, suspended, so it takes a second tap that carries a cue before anything is heard.The pending-resume half of this also hits the README's own async example,
await navigator.clipboard.writeText(); play("success"), when it is the first cue of a visit: the continuation afterawaitis off the click's call stack, WebKit leavesresume()pending, and every laterplay()inherits the silence.Trace from an iPhone
A dev spy wrapped
window.AudioContextand logged the window events. The second column isnavigator.userActivation.hasBeenActive(-false,Atrue). A swipe deck built on a pointer-event drag library; the deck element hastouch-action: none, so swipes end inpointerup, neverpointercancel.Eight swipes, all without activation and without a context. The first tap flips the flag, creates the context inside the
click, andresume()settles 16 ms later. Desktop Chrome is unaffected: it honours sticky activation.Proposed fix
play()the browser blocks, before activation or off a gesture's stack, arms one set of passive capture listeners onwindowfortouchend,click,keydownandmousedown. The first gesture that counts creates the shared context if needed and callsresume()on that stack. The listeners leave once the context runs.resume()renders if it was asked for within the last second, otherwise it is dropped, so a backlog cannot burst out on a later tap.prime()for apps that want to start the context from a gesture of their own.Independent of #14 / #15, which address the cold Chrome renderer.