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
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -278,16 +278,19 @@ 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`). 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=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
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

Expand Down
42 changes: 29 additions & 13 deletions nativeaudio/latency.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
//
Expand All @@ -35,16 +36,27 @@ 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. 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; frames are what the device actually got):
//
// 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
//
// 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)
// 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
Expand All @@ -61,13 +73,17 @@ 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).
func playerBufferBytes(rate int, d time.Duration) int {
Expand Down
22 changes: 17 additions & 5 deletions nativeaudio/latency_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
}
Expand All @@ -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"},
Expand All @@ -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.
func TestOverridesBoundReadAheadByTheDeviceBuffersPlayTime(t *testing.T) {
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 512 frames: err = %v, want ok=%v", v, err, ok)
}
}
}
Loading