diff --git a/README.md b/README.md index a808167..a319e87 100644 --- a/README.md +++ b/README.md @@ -305,12 +305,16 @@ out of a ring, and a goroutine renders 512-frame slices within a 3 ms budget until about 300 ms is queued, then yields to the page. The queue doubles as latency, since a one-shot is rendered behind whatever is already queued: `Options.Queue` sets it and `Player.SetQueue` changes it while playing. -`letgo` asks for 80 ms, because at 300 a game's sounds landed 100–300 ms after -what caused them; 80 ms played without a heard click on an iPhone 13 Pro -(2026-10-26). Where the page is -cross-origin isolated the ring is a SharedArrayBuffer; elsewhere slices travel -as transferred messages. Both played without a dropout on an iPhone, including -under 120 ms main-thread stalls. +`letgo` asks for 60 ms on a cross-origin isolated page, because at 300 a +game's sounds landed 100–300 ms after what caused them. Over minutes of play +in tesserae on an iPhone 13 Pro (2026-10-26), 60, 80, 120 and 200 ms counted +no underruns, and 40 ms counted 8. Elsewhere it asks for 80 ms, since the +message path below was not measured at 60. The cost is stall tolerance: a +main-thread stall longer than the queue empties the ring, so letgo games +trade the 300 ms default's margin for prompt sound. Where the page is +cross-origin isolated the ring is a SharedArrayBuffer; elsewhere slices +travel as transferred messages. Both played without a dropout on an iPhone at +the 300 ms default, including under 120 ms main-thread stalls. Two things the page has to provide: HTTPS (or `localhost`), because Safari offers AudioWorklet only in a secure context; and a tap, because browsers diff --git a/letgo/output_js.go b/letgo/output_js.go index 7521566..c5ed10f 100644 --- a/letgo/output_js.go +++ b/letgo/output_js.go @@ -11,12 +11,26 @@ import ( ) // In the browser the engine plays through webaudio's pump rather than oto, -// whose js backend crackles on phones (see package webaudio). The queue is 80 +// whose js backend crackles on phones (see package webaudio). The queue is 60 // ms, not webaudio's 300, because a game's one-shots render behind it: at 300 -// they landed 100-300 ms after the hit. 80 sounded tighter than 140 and 300, -// with no clicks heard, in tesserae on an iPhone 13 Pro (TinyGo 0.43 bridge on -// the page's main thread beside the terminal, 2026-10-26). That is ear -// evidence, not an underrun count. +// they landed 100-300 ms after the hit. In tesserae (TinyGo 0.43 bridge on the +// page's main thread beside the terminal, 2026-10-26), its audiolab panel on +// an iPhone 13 Pro counted no underruns over minutes of play at 60, 80, 120 +// and 200 ms, and 8 at 40 ms. On desktop Chrome a trigger reached the +// worklet's output 69 ms after dispatch at 60, against 100 ms at 80. The +// price is stall tolerance: a main-thread stall longer than the queue (a GC, +// a heavy redraw) empties the ring and is heard as a gap, where 300 ms rode +// out 120 ms stalls. +// +// tesserae is served cross-origin isolated, so all of that is the +// SharedArrayBuffer ring. The message path webaudio falls back to elsewhere +// counts posted slices as queued before the worklet has them, and on a phone +// dipped ~30 ms deeper than the ring at 300 ms; unmeasured at 60, it keeps +// the 80 ms it had. func openOutput(src io.Reader) (output, error) { - return webaudio.Open(src, webaudio.Options{SampleRate: engine.SampleRate, Queue: 80 * time.Millisecond}) + queue := 60 * time.Millisecond + if !webaudio.SharedAvailable() { + queue = 80 * time.Millisecond + } + return webaudio.Open(src, webaudio.Options{SampleRate: engine.SampleRate, Queue: queue}) } diff --git a/webaudio/webaudio.go b/webaudio/webaudio.go index dfc0470..c7536bb 100644 --- a/webaudio/webaudio.go +++ b/webaudio/webaudio.go @@ -19,7 +19,8 @@ // The queue is also latency: a trigger is rendered behind everything already // queued, so at the default a one-shot lands up to 300 ms after what caused // it. A caller playing one-shots can ask for less, and SetQueue changes it -// while playing; letgo asks for 80 ms (see letgo/output_js.go). +// while playing; letgo asks for 60 ms, or 80 without cross-origin isolation +// (see letgo/output_js.go). // // Where the page is cross-origin isolated, the ring is a SharedArrayBuffer // the pump writes straight into; elsewhere (most hosting, itch.io embeds @@ -119,6 +120,15 @@ class UndertoneShared extends AudioWorkletProcessor { return true; } } registerProcessor('undertone-shared', UndertoneShared);` +// SharedAvailable reports whether a Player opened now would play through the +// SharedArrayBuffer ring rather than the message path: true on a +// cross-origin isolated page. A caller tuning its queue per path asks here +// before Open, so the rule lives in one place; (*Player).Shared reports the +// path a Player actually took. +func SharedAvailable() bool { + return js.Global().Get("crossOriginIsolated").Truthy() +} + // Open creates the AudioContext and starts the pump. It must be called before // the user's unlocking gesture, not inside it: iOS Safari only lets a context // start from a gesture that arrives after the context exists, so Open listens @@ -168,7 +178,7 @@ func Open(src io.Reader, opt Options) (*Player, error) { } // ~1 s in whole slices, so a slice never wraps the shared ring p.cap = opt.Slice * int(math.Ceil(float64(opt.SampleRate)/float64(opt.Slice))) - if js.Global().Get("crossOriginIsolated").Truthy() { + if SharedAvailable() { p.shared = true sab := js.Global().Get("SharedArrayBuffer") samples, ctrl := sab.New(p.cap*8), sab.New(12) diff --git a/webaudio/worker.go b/webaudio/worker.go index 0f590a5..1b51608 100644 --- a/webaudio/worker.go +++ b/webaudio/worker.go @@ -36,7 +36,7 @@ func inWorker() bool { // before the page starts playing. func openWorker(src io.Reader, opt Options) (*Player, error) { g := js.Global() - if !g.Get("crossOriginIsolated").Truthy() { + if !SharedAvailable() { return nil, errors.New("webaudio: playing from a worker needs a cross-origin isolated page (SharedArrayBuffer)") } p := &Player{src: src, opt: opt, shared: true, worker: true, id: int(playerIDs.Add(1))}