From 3e4926335bebf13a973e5adb599418e44b829a95 Mon Sep 17 00:00:00 2001 From: Matt Parrett Date: Tue, 27 Oct 2026 00:16:14 -0500 Subject: [PATCH 1/7] Give letgo's browser output a 60 ms queue tesserae's audiolab panel (2026-10-26) 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. Co-Authored-By: Claude Opus 5.5 --- README.md | 6 +++--- letgo/output_js.go | 14 ++++++++------ webaudio/webaudio.go | 2 +- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index a808167..fabfee2 100644 --- a/README.md +++ b/README.md @@ -305,9 +305,9 @@ 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 +`letgo` asks for 60 ms, because at 300 a game's sounds landed 100–300 ms after +what caused them. Over minutes of play in tesserae (2026-10-26), 60 ms and up +counted no underruns, and 40 ms counted 8. 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. diff --git a/letgo/output_js.go b/letgo/output_js.go index 7521566..b534132 100644 --- a/letgo/output_js.go +++ b/letgo/output_js.go @@ -11,12 +11,14 @@ 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 +// counted no underruns over minutes of play at 60, 80, 120 and 200 ms, and 8 +// at 40 ms; 80 had earlier sounded click-free on an iPhone 13 Pro. On desktop +// Chrome a trigger reached the worklet's output 69 ms after dispatch at 60, +// against 100 ms at 80. func openOutput(src io.Reader) (output, error) { - return webaudio.Open(src, webaudio.Options{SampleRate: engine.SampleRate, Queue: 80 * time.Millisecond}) + return webaudio.Open(src, webaudio.Options{SampleRate: engine.SampleRate, Queue: 60 * time.Millisecond}) } diff --git a/webaudio/webaudio.go b/webaudio/webaudio.go index dfc0470..301b9ff 100644 --- a/webaudio/webaudio.go +++ b/webaudio/webaudio.go @@ -19,7 +19,7 @@ // 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 (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 From 60e4138ca73d40ab99cca3d57d6c5c35d3d2315a Mon Sep 17 00:00:00 2001 From: Matt Parrett Date: Tue, 27 Oct 2026 00:17:08 -0500 Subject: [PATCH 2/7] Say what the 60 ms queue gives up, and date the 120 ms stall result to the 300 ms default Co-Authored-By: Claude Opus 5.5 --- README.md | 8 +++++--- letgo/output_js.go | 4 +++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index fabfee2..cdd1c05 100644 --- a/README.md +++ b/README.md @@ -307,10 +307,12 @@ 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 60 ms, because at 300 a game's sounds landed 100–300 ms after what caused them. Over minutes of play in tesserae (2026-10-26), 60 ms and up -counted no underruns, and 40 ms counted 8. Where the page is +counted no underruns, and 40 ms counted 8. 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, including -under 120 ms main-thread stalls. +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 b534132..d7ffc7a 100644 --- a/letgo/output_js.go +++ b/letgo/output_js.go @@ -18,7 +18,9 @@ import ( // counted no underruns over minutes of play at 60, 80, 120 and 200 ms, and 8 // at 40 ms; 80 had earlier sounded click-free on an iPhone 13 Pro. On desktop // Chrome a trigger reached the worklet's output 69 ms after dispatch at 60, -// against 100 ms at 80. +// 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. func openOutput(src io.Reader) (output, error) { return webaudio.Open(src, webaudio.Options{SampleRate: engine.SampleRate, Queue: 60 * time.Millisecond}) } From d1c3a9f3a50dadbca8dbf0a0e44496d3b7f22a9c Mon Sep 17 00:00:00 2001 From: Matt Parrett Date: Tue, 27 Oct 2026 00:24:14 -0500 Subject: [PATCH 3/7] Name the phone the underrun counts came from Co-Authored-By: Claude Opus 5.5 --- README.md | 4 ++-- letgo/output_js.go | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index cdd1c05..3c21db0 100644 --- a/README.md +++ b/README.md @@ -306,8 +306,8 @@ 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 60 ms, because at 300 a game's sounds landed 100–300 ms after -what caused them. Over minutes of play in tesserae (2026-10-26), 60 ms and up -counted no underruns, and 40 ms counted 8. The cost is stall tolerance: a +what caused them. Over minutes of play in tesserae on an iPhone 13 Pro +(2026-10-26), 60 ms and up counted no underruns, and 40 ms counted 8. 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 diff --git a/letgo/output_js.go b/letgo/output_js.go index d7ffc7a..9ef646c 100644 --- a/letgo/output_js.go +++ b/letgo/output_js.go @@ -14,9 +14,9 @@ import ( // 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. In tesserae (TinyGo 0.43 bridge on the -// page's main thread beside the terminal, 2026-10-26), its audiolab panel -// counted no underruns over minutes of play at 60, 80, 120 and 200 ms, and 8 -// at 40 ms; 80 had earlier sounded click-free on an iPhone 13 Pro. On desktop +// 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 From c2f51aa4a99b0a4709d54e80f2ceb39d8f735be2 Mon Sep 17 00:00:00 2001 From: Matt Parrett Date: Tue, 27 Oct 2026 00:24:25 -0500 Subject: [PATCH 4/7] Rewrap the queue comment Co-Authored-By: Claude Opus 5.5 --- letgo/output_js.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/letgo/output_js.go b/letgo/output_js.go index 9ef646c..abe76e5 100644 --- a/letgo/output_js.go +++ b/letgo/output_js.go @@ -16,11 +16,11 @@ import ( // 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. +// 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. func openOutput(src io.Reader) (output, error) { return webaudio.Open(src, webaudio.Options{SampleRate: engine.SampleRate, Queue: 60 * time.Millisecond}) } From 9ab74ea7ae9592a06b432492ce791c0798781ab4 Mon Sep 17 00:00:00 2001 From: Matt Parrett Date: Tue, 27 Oct 2026 00:25:41 -0500 Subject: [PATCH 5/7] Keep 80 ms on the unmeasured message path; 60 ms only where the ring is shared Co-Authored-By: Claude Opus 5.5 --- README.md | 14 ++++++++------ letgo/output_js.go | 13 ++++++++++++- webaudio/webaudio.go | 3 ++- 3 files changed, 22 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 3c21db0..15ea23f 100644 --- a/README.md +++ b/README.md @@ -305,14 +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 60 ms, 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 ms and up counted no underruns, and 40 ms counted 8. The cost is stall tolerance: a +`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 ms and up 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. +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 abe76e5..89fb08a 100644 --- a/letgo/output_js.go +++ b/letgo/output_js.go @@ -4,6 +4,7 @@ package letgo import ( "io" + "syscall/js" "time" "github.com/mparrett/undertone/engine" @@ -21,6 +22,16 @@ import ( // 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: 60 * time.Millisecond}) + queue := 60 * time.Millisecond + if !js.Global().Get("crossOriginIsolated").Truthy() { + 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 301b9ff..a989ce8 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 60 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 From 6ab2f033769dc04176b448e840d53aa7be402749 Mon Sep 17 00:00:00 2001 From: Matt Parrett Date: Tue, 27 Oct 2026 00:26:44 -0500 Subject: [PATCH 6/7] Ask webaudio which path it plays through, rather than repeating its test webaudio.Shared reports whether a Player opened now uses the SharedArrayBuffer ring; Open, openWorker and letgo's queue choice all use it. Co-Authored-By: Claude Opus 5.5 --- letgo/output_js.go | 3 +-- webaudio/webaudio.go | 10 +++++++++- webaudio/worker.go | 2 +- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/letgo/output_js.go b/letgo/output_js.go index 89fb08a..fdc8706 100644 --- a/letgo/output_js.go +++ b/letgo/output_js.go @@ -4,7 +4,6 @@ package letgo import ( "io" - "syscall/js" "time" "github.com/mparrett/undertone/engine" @@ -30,7 +29,7 @@ import ( // the 80 ms it had. func openOutput(src io.Reader) (output, error) { queue := 60 * time.Millisecond - if !js.Global().Get("crossOriginIsolated").Truthy() { + if !webaudio.Shared() { 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 a989ce8..91f850c 100644 --- a/webaudio/webaudio.go +++ b/webaudio/webaudio.go @@ -120,6 +120,14 @@ class UndertoneShared extends AudioWorkletProcessor { return true; } } registerProcessor('undertone-shared', UndertoneShared);` +// Shared reports whether a Player opened now plays 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, +// so the rule lives in one place. +func Shared() 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 @@ -169,7 +177,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 Shared() { 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..5e9db35 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 !Shared() { 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))} From 480f5923cf57c1e73309e2b12109dc5aa413fc8c Mon Sep 17 00:00:00 2001 From: Matt Parrett Date: Tue, 27 Oct 2026 00:27:43 -0500 Subject: [PATCH 7/7] Name the pre-Open path check SharedAvailable, apart from (*Player).Shared, and list the queues measured Co-Authored-By: Claude Opus 5.5 --- README.md | 4 ++-- letgo/output_js.go | 2 +- webaudio/webaudio.go | 11 ++++++----- webaudio/worker.go | 2 +- 4 files changed, 10 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 15ea23f..a319e87 100644 --- a/README.md +++ b/README.md @@ -307,8 +307,8 @@ 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 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 ms and up counted no -underruns, and 40 ms counted 8. Elsewhere it asks for 80 ms, since the +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 diff --git a/letgo/output_js.go b/letgo/output_js.go index fdc8706..c5ed10f 100644 --- a/letgo/output_js.go +++ b/letgo/output_js.go @@ -29,7 +29,7 @@ import ( // the 80 ms it had. func openOutput(src io.Reader) (output, error) { queue := 60 * time.Millisecond - if !webaudio.Shared() { + 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 91f850c..c7536bb 100644 --- a/webaudio/webaudio.go +++ b/webaudio/webaudio.go @@ -120,11 +120,12 @@ class UndertoneShared extends AudioWorkletProcessor { return true; } } registerProcessor('undertone-shared', UndertoneShared);` -// Shared reports whether a Player opened now plays through the +// 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, -// so the rule lives in one place. -func Shared() bool { +// 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() } @@ -177,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 Shared() { + 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 5e9db35..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 !Shared() { + 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))}