diff --git a/README.md b/README.md index 2bb483d..200dabe 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/nativeaudio/latency.go b/nativeaudio/latency.go index fde8997..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. // @@ -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 @@ -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 { diff --git a/nativeaudio/latency_test.go b/nativeaudio/latency_test.go index 86b9539..153c654 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 { @@ -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) } } @@ -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"}, @@ -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) + } + } +}