From e723d28df0fdf599d818105679a2eb167fd7e3ca Mon Sep 17 00:00:00 2001 From: Matt Parrett Date: Mon, 26 Oct 2026 23:16:54 -0500 Subject: [PATCH 1/4] Bound the read-ahead override by the device buffer's play time The check added in #74 compared UNDERTONE_PLAYER_BUFFER against deviceBuffer, the duration handed to oto. oto converts that duration at the int16 rate but sizes the buffer in float32 samples, so it is twice the time the buffer holds: 23.2 ms for 512 frames at 44.1 kHz, which plays in 11.6 ms. Read-aheads of 12-23 ms at the default device size were refused. deviceLatency gives the play time, and the check and its message use it. Co-Authored-By: Claude Opus 5.5 --- nativeaudio/latency.go | 13 +++++++++++-- nativeaudio/latency_test.go | 16 ++++++++++++++-- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/nativeaudio/latency.go b/nativeaudio/latency.go index fde8997..a048b4b 100644 --- a/nativeaudio/latency.go +++ b/nativeaudio/latency.go @@ -70,6 +70,15 @@ func deviceBuffer(rate int, frames int64) time.Duration { // playerBufferBytes is a read-ahead of d as oto's SetBufferSize wants it: // bytes, aligned to a whole frame (2 channels x 2 bytes for signed 16-bit LE). +// deviceLatency is how long a device buffer of frames takes to play. It is not +// deviceBuffer: oto sizes the device buffer in 4-byte float32 samples but +// converts the duration at the 2-byte int16 rate, so deviceBuffer's duration +// is twice the time the buffer holds (512 frames at 44.1 kHz: 23.2 ms for +// oto, 11.6 ms of sound). +func deviceLatency(rate int, frames int64) time.Duration { + return time.Duration(frames * int64(time.Second) / int64(rate)) +} + func playerBufferBytes(rate int, d time.Duration) int { n := int(int64(d) * int64(rate) * bytesPerFrame / int64(time.Second)) return n / bytesPerFrame * bytesPerFrame @@ -104,9 +113,9 @@ func overrides(rate int, queue time.Duration) (time.Duration, int64, error) { // Shorter than one device buffer can't help (the device drains whole // buffers), and under one frame it rounds to 0 bytes, which oto reads as // "use the half-second default" — the silent fallback this rejects. - if queue < deviceBuffer(rate, frames) { + if queue < deviceLatency(rate, frames) { return 0, 0, fmt.Errorf("nativeaudio: a read-ahead of %v is shorter than the %d-frame device buffer (%v)", - queue, frames, deviceBuffer(rate, frames)) + queue, frames, deviceLatency(rate, frames)) } return queue, frames, nil } diff --git a/nativeaudio/latency_test.go b/nativeaudio/latency_test.go index 86b9539..3aff219 100644 --- a/nativeaudio/latency_test.go +++ b/nativeaudio/latency_test.go @@ -47,9 +47,9 @@ func TestPlayerBufferIsWellFormedAndSmallerThanOtosDefault(t *testing.T) { if got >= otoDefault { t.Errorf("player buffer %d >= oto's default %d — the point is to be smaller", got, otoDefault) } - if playerBuffer < deviceBuffer(rate, deviceFrames) { + if playerBuffer < deviceLatency(rate, deviceFrames) { t.Errorf("player buffer (%v) below device buffer (%v): the device drains in whole "+ - "buffers, so read-ahead shorter than one cannot help", playerBuffer, deviceBuffer(rate, deviceFrames)) + "buffers, so read-ahead shorter than one cannot help", playerBuffer, deviceLatency(rate, deviceFrames)) } } @@ -98,3 +98,15 @@ func TestOverridesDefaultToTheCaller(t *testing.T) { t.Fatalf("overrides = %v, %d, %v; want the caller's 500ms and %d", q, f, err, deviceFrames) } } + +// 512 frames at 44.1 kHz hold 11.6 ms, so 12 ms of read-ahead is legal and +// 11 ms is not. The check once used deviceBuffer's 23.2 ms and refused 12–23 ms. +func TestOverridesBoundReadAheadByTheDeviceBuffersPlayTime(t *testing.T) { + t.Setenv("UNDERTONE_DEVICE_FRAMES", "") + for v, ok := range map[string]bool{"12ms": true, "11ms": false} { + t.Setenv("UNDERTONE_PLAYER_BUFFER", v) + if _, _, err := overrides(44100, 40*time.Millisecond); (err == nil) != ok { + t.Errorf("UNDERTONE_PLAYER_BUFFER=%s with %d frames: err = %v, want ok=%v", v, deviceFrames, err, ok) + } + } +} From 462b6058d03cdf6ab45192bc2237bd38810e0587 Mon Sep 17 00:00:00 2001 From: Matt Parrett Date: Mon, 26 Oct 2026 23:17:53 -0500 Subject: [PATCH 2/4] Replace the native latency table with a speaker-to-mic measurement The table in latency.go and the README's 820 -> 130 ms figures were measured 2026-08-24 in headless Chromium through oto's web backend, timed by Playwright, and read as native numbers. Replace them with medians from a speaker-to-mic loopback on a MacBook Air, 2026-10-26, and say where the old figures came from. Co-Authored-By: Claude Opus 5.5 --- README.md | 9 ++++++--- nativeaudio/latency.go | 17 ++++++++++++----- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 2bb483d..9243099 100644 --- a/README.md +++ b/README.md @@ -280,14 +280,17 @@ itself. The package keeps oto's rules in one place: alive. - **Short buffers.** It opens oto with a 512-frame device buffer and a 40 ms read-ahead in place of oto's streaming-oriented defaults - (`nativeaudio/latency.go`). On the measuring bench, tap-to-sound dropped - from a median of about 820 ms to about 130 ms. `Options.Queue` sets the + (`nativeaudio/latency.go`). Measured 2026-10-26 on a MacBook Air, speaker + to mic, trigger-to-sound is a median of 150–158 ms at these defaults, and + 97 ms with `UNDERTONE_PLAYER_BUFFER=12ms UNDERTONE_DEVICE_FRAMES=256`; 128 + frames broke up. (The older 820 → 130 ms figures came from headless + Chromium through oto's web backend, 2026-08-24.) `Options.Queue` sets the read-ahead: the CLI asks for 500 ms, since a bed alone has no one-shots waiting behind it and the slack survives a slow terminal. For latency experiments without a rebuild, `UNDERTONE_PLAYER_BUFFER` (a duration such as `20ms`) and `UNDERTONE_DEVICE_FRAMES` (a power of two from 64 to 16384) override both buffers; a bad value, or a read-ahead shorter than the device -buffer, is an error from `Open`. + buffer, is an error from `Open`. ## In the browser diff --git a/nativeaudio/latency.go b/nativeaudio/latency.go index a048b4b..e4c5d03 100644 --- a/nativeaudio/latency.go +++ b/nativeaudio/latency.go @@ -39,12 +39,19 @@ const ( deviceFrames int64 = 512 // playerBuffer is the player's read-ahead, and the knob that actually - // moves latency. Measured on the bench, tap to first sample tracks it - // almost linearly — about a 90 ms fixed floor plus 1.4x this value: + // moves latency. Native, measured 2026-10-26 on a MacBook Air (built-in + // speakers into its own mic, trigger to the blip's onset in the + // recording; medians of 15): // - // 20 ms -> 117 ms 60 ms -> 187 ms - // 40 ms -> 129 ms 120 ms -> 256 ms - // 500 ms -> 821 ms (oto's default, before any of this) + // 40 ms read-ahead, 512 frames -> 150-158 ms (these defaults) + // 24 ms read-ahead, 256 frames -> 117 ms + // 12 ms read-ahead, 256 frames -> 97 ms + // 128 frames, at 6 or 12 ms -> breaks up; no clean onsets + // + // The earlier table here (a 90 ms floor plus 1.4x the read-ahead, and + // 821 ms at oto's 500 ms default) was measured 2026-08-24 in headless + // Chromium through oto's web backend, timed by Playwright: a browser + // figure, not a native one. // // 40 ms is chosen for slack rather than for the last few milliseconds: // with the device buffer it leaves ~63 ms for the renderer to stall From 03d6e1374bf12201fc4628cef07e22a716122638 Mon Sep 17 00:00:00 2001 From: Matt Parrett Date: Mon, 26 Oct 2026 23:19:47 -0500 Subject: [PATCH 3/4] Make deviceFrames the device's real size, and keep it at 1024 The first commit here had oto's conversion backwards. oto turns BufferSize into bytes at 8 a frame (stereo float32, context.go), so deviceBuffer's duration was the buffer's true play time, and its 4-byte arithmetic was what doubled the frame count: deviceFrames = 512 opened a 1024-frame device, and UNDERTONE_DEVICE_FRAMES=N opened 2N. The round-trip tests modeled oto at 4 bytes too, so they agreed. deviceBuffer and the tests now convert at oto's 8 bytes, deviceLatency is gone, and deviceFrames is 1024 so the device stays the size it has been. The override check is the one #74 shipped. The measured table and the README now give the frame counts the device actually got. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 ++-- nativeaudio/latency.go | 41 ++++++++++++++++++------------------- nativeaudio/latency_test.go | 18 ++++++++-------- 3 files changed, 31 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 9243099..200dabe 100644 --- a/README.md +++ b/README.md @@ -278,11 +278,11 @@ itself. The package keeps oto's rules in one place: `cmd/undertone/playerlifetime_test.go` parses every `NewPlayer` call site in the module and fails on one that is neither stored in a field nor kept alive. -- **Short buffers.** It opens oto with a 512-frame device buffer and a 40 ms +- **Short buffers.** It opens oto with a 1024-frame device buffer and a 40 ms read-ahead in place of oto's streaming-oriented defaults (`nativeaudio/latency.go`). Measured 2026-10-26 on a MacBook Air, speaker to mic, trigger-to-sound is a median of 150–158 ms at these defaults, and - 97 ms with `UNDERTONE_PLAYER_BUFFER=12ms UNDERTONE_DEVICE_FRAMES=256`; 128 + 97 ms with `UNDERTONE_PLAYER_BUFFER=12ms UNDERTONE_DEVICE_FRAMES=512`; 256 frames broke up. (The older 820 → 130 ms figures came from headless Chromium through oto's web backend, 2026-08-24.) `Options.Queue` sets the read-ahead: the CLI asks for 500 ms, since a bed alone has no one-shots diff --git a/nativeaudio/latency.go b/nativeaudio/latency.go index e4c5d03..8e34940 100644 --- a/nativeaudio/latency.go +++ b/nativeaudio/latency.go @@ -35,18 +35,22 @@ const ( // ScriptProcessorNode fallback passes exactly this number to // createScriptProcessor, which throws IndexSizeError for anything else. // Browsers now play through webaudio, but the round trip below still - // holds it. oto's own default is 2048; 512 is two steps down. - deviceFrames int64 = 512 + // holds it. On macOS oto splits it across four AudioQueue buffers. + // + // 1024 is what every build before 2026-10-26 actually got: deviceBuffer + // then converted at 4 bytes a frame while oto converts at 8, so the 512 + // written here opened a 1024-frame device. + deviceFrames int64 = 1024 // playerBuffer is the player's read-ahead, and the knob that actually // moves latency. Native, measured 2026-10-26 on a MacBook Air (built-in // speakers into its own mic, trigger to the blip's onset in the - // recording; medians of 15): + // recording; medians of 15; frames are what the device actually got): // - // 40 ms read-ahead, 512 frames -> 150-158 ms (these defaults) - // 24 ms read-ahead, 256 frames -> 117 ms - // 12 ms read-ahead, 256 frames -> 97 ms - // 128 frames, at 6 or 12 ms -> breaks up; no clean onsets + // 40 ms read-ahead, 1024 frames -> 150-158 ms (these defaults) + // 24 ms read-ahead, 512 frames -> 117 ms + // 12 ms read-ahead, 512 frames -> 97 ms + // 256 frames, at 6 or 12 ms -> breaks up; no clean onsets // // The earlier table here (a 90 ms floor plus 1.4x the read-ahead, and // 821 ms at oto's 500 ms default) was measured 2026-08-24 in headless @@ -68,24 +72,19 @@ const ( // exactly-but-short lands a byte low and then aligns down to one frame less — // turning 512 into 511 and losing the power of two. Rounding up costs a // fraction of a millisecond and makes the round trip exact. (A flat slack -// term does not work: 1 ms is 22 frames too many.) +// term does not work: 1 ms is 44 frames too many.) func deviceBuffer(rate int, frames int64) time.Duration { - deviceBytes := frames * 8 - perSecond := int64(rate) * bytesPerFrame + deviceBytes := frames * otoBytesPerFrame + perSecond := int64(rate) * otoBytesPerFrame return time.Duration((deviceBytes*int64(time.Second) + perSecond - 1) / perSecond) } +// otoBytesPerFrame is how oto converts NewContextOptions.BufferSize to bytes: +// its drivers always run in stereo float32, whatever format players use. +const otoBytesPerFrame = 2 * 4 + // playerBufferBytes is a read-ahead of d as oto's SetBufferSize wants it: // bytes, aligned to a whole frame (2 channels x 2 bytes for signed 16-bit LE). -// deviceLatency is how long a device buffer of frames takes to play. It is not -// deviceBuffer: oto sizes the device buffer in 4-byte float32 samples but -// converts the duration at the 2-byte int16 rate, so deviceBuffer's duration -// is twice the time the buffer holds (512 frames at 44.1 kHz: 23.2 ms for -// oto, 11.6 ms of sound). -func deviceLatency(rate int, frames int64) time.Duration { - return time.Duration(frames * int64(time.Second) / int64(rate)) -} - func playerBufferBytes(rate int, d time.Duration) int { n := int(int64(d) * int64(rate) * bytesPerFrame / int64(time.Second)) return n / bytesPerFrame * bytesPerFrame @@ -120,9 +119,9 @@ func overrides(rate int, queue time.Duration) (time.Duration, int64, error) { // Shorter than one device buffer can't help (the device drains whole // buffers), and under one frame it rounds to 0 bytes, which oto reads as // "use the half-second default" — the silent fallback this rejects. - if queue < deviceLatency(rate, frames) { + if queue < deviceBuffer(rate, frames) { return 0, 0, fmt.Errorf("nativeaudio: a read-ahead of %v is shorter than the %d-frame device buffer (%v)", - queue, frames, deviceLatency(rate, frames)) + queue, frames, deviceBuffer(rate, frames)) } return queue, frames, nil } diff --git a/nativeaudio/latency_test.go b/nativeaudio/latency_test.go index 3aff219..137c106 100644 --- a/nativeaudio/latency_test.go +++ b/nativeaudio/latency_test.go @@ -20,10 +20,10 @@ func TestDeviceBufferSurvivesOtosRoundTripAsAPowerOfTwo(t *testing.T) { func deviceRoundTrip(t *testing.T, rate int) { const channelCount = 2 - bytesPerSecond := rate * bytesPerFrame + bytesPerSecond := rate * otoBytesPerFrame b := int(int64(deviceBuffer(rate, deviceFrames)) * int64(bytesPerSecond) / int64(time.Second)) - b = b / bytesPerFrame * bytesPerFrame // oto's alignment step + b = b / otoBytesPerFrame * otoBytesPerFrame // oto's alignment step frames := b / 4 / channelCount if frames&(frames-1) != 0 || frames == 0 { @@ -47,9 +47,9 @@ func TestPlayerBufferIsWellFormedAndSmallerThanOtosDefault(t *testing.T) { if got >= otoDefault { t.Errorf("player buffer %d >= oto's default %d — the point is to be smaller", got, otoDefault) } - if playerBuffer < deviceLatency(rate, deviceFrames) { + if playerBuffer < deviceBuffer(rate, deviceFrames) { t.Errorf("player buffer (%v) below device buffer (%v): the device drains in whole "+ - "buffers, so read-ahead shorter than one cannot help", playerBuffer, deviceLatency(rate, deviceFrames)) + "buffers, so read-ahead shorter than one cannot help", playerBuffer, deviceBuffer(rate, deviceFrames)) } } @@ -63,8 +63,8 @@ func TestOverridesReadTheEnvironment(t *testing.T) { // Every override still survives oto's round trip as a power of two. for n := int64(64); n <= 16384; n *= 2 { for _, rate := range []int{44100, 48000} { - b := int(int64(deviceBuffer(rate, n)) * int64(rate*bytesPerFrame) / int64(time.Second)) - if got := int64(b / bytesPerFrame * bytesPerFrame / 4 / 2); got != n { + b := int(int64(deviceBuffer(rate, n)) * int64(rate*otoBytesPerFrame) / int64(time.Second)) + if got := int64(b / otoBytesPerFrame * otoBytesPerFrame / 4 / 2); got != n { t.Errorf("%d frames at %d Hz round-trips to %d", n, rate, got) } } @@ -100,13 +100,13 @@ func TestOverridesDefaultToTheCaller(t *testing.T) { } // 512 frames at 44.1 kHz hold 11.6 ms, so 12 ms of read-ahead is legal and -// 11 ms is not. The check once used deviceBuffer's 23.2 ms and refused 12–23 ms. +// 11 ms is not. func TestOverridesBoundReadAheadByTheDeviceBuffersPlayTime(t *testing.T) { - t.Setenv("UNDERTONE_DEVICE_FRAMES", "") + t.Setenv("UNDERTONE_DEVICE_FRAMES", "512") for v, ok := range map[string]bool{"12ms": true, "11ms": false} { t.Setenv("UNDERTONE_PLAYER_BUFFER", v) if _, _, err := overrides(44100, 40*time.Millisecond); (err == nil) != ok { - t.Errorf("UNDERTONE_PLAYER_BUFFER=%s with %d frames: err = %v, want ok=%v", v, deviceFrames, err, ok) + t.Errorf("UNDERTONE_PLAYER_BUFFER=%s with 512 frames: err = %v, want ok=%v", v, err, ok) } } } From 041cd1b8886b63098f85e01fde2f078fe232bd44 Mon Sep 17 00:00:00 2001 From: Matt Parrett Date: Mon, 26 Oct 2026 23:21:56 -0500 Subject: [PATCH 4/4] Label the 821 ms bench figure as a browser measurement, and unpin a test comment from 512 frames Co-Authored-By: Claude Opus 5.5 --- nativeaudio/latency.go | 7 ++++--- nativeaudio/latency_test.go | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/nativeaudio/latency.go b/nativeaudio/latency.go index 8e34940..c938687 100644 --- a/nativeaudio/latency.go +++ b/nativeaudio/latency.go @@ -16,9 +16,10 @@ import ( // half second ahead (mux.defaultBufferSize). For a bed that is correct and // invisible. For one-shots it is not — a trigger staged now waits for all // that already-queued audio to drain, so a footstep lands somewhere in the -// next half second. Measured on the tuning bench before this: median 821 ms -// from tap to first sample, ranging 33 ms to over a second depending on where -// in the buffer cycle the tap fell. The variance is the worse half; a late +// next half second. Before this, the tuning bench (headless Chromium through +// oto's web backend, 2026-08-24) measured a median of 821 ms from tap to +// first sample, ranging 33 ms to over a second depending on where in the +// buffer cycle the tap fell. The variance is the worse half; a late // sound is survivable, a sound whose delay changes every time cannot be // played around. // diff --git a/nativeaudio/latency_test.go b/nativeaudio/latency_test.go index 137c106..153c654 100644 --- a/nativeaudio/latency_test.go +++ b/nativeaudio/latency_test.go @@ -76,7 +76,7 @@ func TestOverridesRejectBadValues(t *testing.T) { {"UNDERTONE_PLAYER_BUFFER", "fast"}, {"UNDERTONE_PLAYER_BUFFER", "-5ms"}, {"UNDERTONE_PLAYER_BUFFER", "10us"}, // 0 bytes: oto would read it as its 500 ms default - {"UNDERTONE_PLAYER_BUFFER", "5ms"}, // shorter than the 512-frame device buffer + {"UNDERTONE_PLAYER_BUFFER", "5ms"}, // shorter than the device buffer (deviceFrames) {"UNDERTONE_DEVICE_FRAMES", "300"}, {"UNDERTONE_DEVICE_FRAMES", "32"}, {"UNDERTONE_DEVICE_FRAMES", "32768"},