Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 20 additions & 6 deletions letgo/output_js.go
Original file line number Diff line number Diff line change
Expand Up @@ -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})
}
14 changes: 12 additions & 2 deletions webaudio/webaudio.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion webaudio/worker.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))}
Expand Down
Loading