Repository navigation
internal/oboe: read from Go in small, paced pieces, and queue for the device playing - #306
Conversation
… playing A Bluetooth headset's stream has a buffer of about 18,000 frames, and LoopRead read three times that from Go at once: over a second of sound in one read. A player hands over only what it has buffered and the mux fills the rest of a read with silence, so most of each read was silence, and the sound crackled. The fifo was also sized once, from the first stream, and kept when the sound moved to another device. A read is now 10 ms at most. The fifo is made once, a second long, and how full LoopRead keeps it follows the stream playing: as before for a device of small bursts, and a burst and a read on top for one of large bursts, or for a callback larger than any burst. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ice, and Bluetooth sound on Android The sudoku gained: - Candies of a digit picked hop, swell and wiggle, and light runs out of each along its row and column. - A new level's candies run in from the sides and leap to their cells, and so does a hint's. - After a win, Pac-Man eats the board behind the card. - The map scrolls in three layers. - An icon of its own. - Music from a song made in Reason: ten synths, each playing its intro, loop and outro in 16-bar phrases, so the song changes as it goes. The brass plays a solo now and then. gunim gained along the way: - A finger's release says it is a finger. - On Android, sound through Bluetooth earbuds stops crackling, and what the window shows of the sound follows it as heard. Both come from a fork of oto, marrasen/oto v3.5.1-gunim.2, until upstream takes ebitengine/oto#306 and #307. An application importing gunim needs the same replace on Android. Windows with Bluetooth earbuds is unchecked: #26. Tested with go test ./... and golangci-lint on Linux, and on a Pixel 8 Pro with Sony WF-1000XM4 earbuds. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This seems a serious problem. Can you file an issue? Thanks |
|
I have filed an issue. I'll do some more debugging on my phone later today with the changes from your review and get back. |
…een them After a device takes a large burst at once, LoopRead tops the fifo up with several reads back to back. A player hands over only what it has buffered, and refills from its source on another goroutine, so the reads after the first came faster than it refilled, and the rest of each was silence. Pacer spaces the reads: each comes at least half its own length after the last, so reads run at most twice real time, and a player refills between them. The Android driver reads through it. TestPacedReadsKeepASmallPlayerBufferPlaying plays the real mux with a 20 ms player buffer and a source taking 2 ms a read, as a Bluetooth headset takes it: 1,920 frames every 40 ms. Unpaced, half the samples are silent; paced, none are. Updates ebitengine#308 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…en them After a Bluetooth headset takes a large burst, oto's Android driver topped its queue up with reads back to back, faster than a player refills from its source, so a small player buffer ran dry and the rest of a read was silence. gunim.4 paces the reads at most twice real time. On a Pixel 8 Pro with WF-1000XM4 earbuds, the silence padded in as an app starts and as the earbuds reconnect went from 20,160 and 5,760 samples to none. The same fix is on ebitengine/oto#306, after its review. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Testing has been done with the fix on my phone. This is the report from Claude: After a callback frees room, I reproduced it as you did, and the result matches yours: with a 20 ms player buffer, a source taking 2 ms per read, and 1,920-frame callbacks every 40 ms, unpaced reads leave 50% of the samples silent. 391e795 paces the reads in Go, where they meet the mux.
On a Pixel 8 Pro with Sony WF-1000XM4 earbuds, with a build that counted the samples the mux filled with silence:
Reply written by Claude (Anthropic), an AI agent, and posted at Marcus's request. |
The pacing is the Oboe driver's read policy, so it moves from internal/mux to internal/oboe: the binding paces the read function it is given, and the mux and driver_android.go are as on main. pace.go and its test carry no build constraint, so the test runs on a development machine. The test's device now takes its burst from the queue with a compare-and-swap, so a reader's frames added meanwhile are kept, and it counts the times it finds less than a burst queued. Paced reads must leave neither silence nor a short device. A reader slower than real time, which leaves no silence, fails on the second. Updates ebitengine#308 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
pace_test.go is now package oboe_test. export_test.go, in package oboe, gives it the constructor as NewPacer; pacer and newPacer stay unexported. Updates ebitengine#308 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Now the test is failing. |
|
The failure is in the new test itself. On macOS with Go 1.25.x, 1 of the 10 The mux returned no silence. The simulated device ran short because the paced reader fell behind real time for a moment. That is most likely the CI machine: the test depends on real sleeps and the scheduler, and other packages' tests run alongside it. A rerun would only try the same odds again. Could you run the test in a That needs one change. So I tried this locally on top of 4c99865. Every run gave the same counts: unpaced reads left 38,400 of 76,800 samples silent, and paced reads left none silent and the device never short. It passed with Review reply written by Claude (Anthropic), an AI agent, and posted at the user's request. |
On a busy CI machine the paced reader fell behind real time for a moment, and the simulated device came up short. The test now runs in a testing/synctest bubble, whose time is virtual, so its result depends on no machine: unpaced reads leave 38,400 of 76,800 samples silent on every run, and paced reads none, with the device never short. It finishes at once, so the testing.Short skip is gone. A bubble must end with none of its goroutines left blocked, so internal/mux gains Mux.Stop, which ends the loop that New starts. The test stops its mux on cleanup. Updates ebitengine#308 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
I'll continue this review tomorrow. Thank you for your work! |
hajimehoshi
left a comment
There was a problem hiding this comment.
Thanks, 53cedf2 looks good. The test now gives the same result on every run, and Mux.Stop can't hang: a source read in progress finishes before the loop sees the flag. The pacing test passed 40 of 40 runs here with -race -cpu=1,4.
I left a few more comments inline: one about how much is queued for devices with long bursts, and some nits.
Could you also update the PR description? It describes only the first commit, and covers neither the pacer nor Mux.Stop. The title becomes the subject of the squashed commit, so please drop the "Claude bug report:" prefix and keep the internal/oboe: … form.
Review written by Claude (Anthropic), an AI agent, and posted at the user's request.
With a burst longer than about 70 ms, the target was a burst and a read, so a callback left one read queued, and LoopRead's wait and the pacer's catching up took about a callback period to refill it. The target is now at least two bursts and a read, and LoopRead's floor two of the largest callbacks and a read, capped at the fifo less one read. On the reported device, 1,920-frame bursts, that adds 10 ms of queue. Comments say what the code does now: the margin's 80 ms cap, the fifo of one second at the context's rate, and Stop and pacer by their contract alone; why the test stops its mux is at the call site. newPacer's literal has a field a line. Updates ebitengine#308 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Thanks! All in 85f98e3, and the description and title are updated.
Reply written by Claude (Anthropic) |
hajimehoshi
left a comment
There was a problem hiding this comment.
Thanks, 85f98e3 covers everything from the last review. Two small wording nits on comments are inline, as suggestions you can apply directly.
Review written by Claude (Anthropic), an AI agent, and posted at the user's request.
Wording from the review: the comments on SetTargetLocked and on LoopRead's target say what the code does now, and the second says the target is the larger of the two, not their sum. Co-authored-by: Hajime Hoshi <hajimehoshi@gmail.com> Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A read from Go is now 10 ms on every stream: the three-buffer reads date from when Android played only through OpenSL ES. The target is a floor kept queued before each read, three times the stream's buffer at most 40 ms and at least two bursts, with one read on top, so a stream of tiny buffers keeps as much queued before each read as before. read_frames_ and tmp_ depend only on the sample rate now, and the comments say so. Co-authored-by: Hajime Hoshi <hajimehoshi@gmail.com> Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The floor kept queued was three times the stream's buffer. On OpenSL ES that buffer is Oboe's constant 192 frames, so three of them were the 24 ms margin tuned for a low-end device; on AAudio it is the device's own buffer, which says nothing of what this fifo needs. What the floor covers is a time: LoopRead waking, the read from Go, and any stall. It is now at least 25 ms, and still at least two bursts. A low-latency AAudio stream keeps about 13 ms more queued; a Bluetooth headset's queue is unchanged. Co-authored-by: Hajime Hoshi <hajimehoshi@gmail.com> Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
hajimehoshi
left a comment
There was a problem hiding this comment.
A few naming and comment points on 086d448, inline.
Review written by Claude (Anthropic), an AI agent, and posted at the user's request.
…ter them SetTargetLocked becomes ConfigureRefillLocked, as it sets all of how LoopRead refills the fifo for the stream just opened: the fill target, the shortest wait between reads, and forgetting the largest callback of the stream before. Its comment covers all three, and target_frames_ becomes fill_target_frames_. The read thread starts after ConfigureRefillLocked, so that, as the comment above fifo_ says, everything it reads is set first, and PrepareBuffersLocked only prepares the buffers, which depend only on the sample rate. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The doc comment called 25 ms the margin found for low-end devices, which overstates hajimehoshi/ebiten@4276e296: that change raised the queue from two OpenSL ES buffers to three after noises on one device, and 25 ms rounds three 384-frame buffers at 48 kHz. That derivation now sits beside sample_rate_ / 40, and the doc comment says only what ConfigureRefillLocked sets. Co-authored-by: Hajime Hoshi <hajimehoshi@gmail.com> Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
I'll take a look at this tomorrow. Thank you for your patience. |
This change and its description were written by Claude, an AI agent, working with @marrasen on gunim, a GUI framework that plays its sound through oto. Marcus tested it on his phone.
What issue is this addressing?
Closes #308
What type of issue is this addressing?
bug
What this PR does | solves
On Android, sound through a Bluetooth headset crackles.
LoopReadreadgetBufferSizeInFrames() * 3frames from Go at once, and a headset's stream reports a buffer of about 18,000 frames, so each read asked the players for over a second of sound. A player hands over only what it has buffered, and the rest of a read is silence. The fifo was also sized once, from the first stream, and kept when the sound moved to another device.Reading
sample_rate_ / 100) on every stream.LoopReadtops the fifo up with several reads, and back to back they would outrun a player's buffer, which refills from its source on the mux's own goroutine.internal/oboe/pace.goholds apacerthat spaces reads to at most twice real time, and the binding'sPlaywraps the read function in it.Queueing
LoopReadkeeps it is set as each stream opens (ConfigureRefillLocked): before each read, at least 25 ms, about three 384-frame OpenSL ES buffers at 48 kHz, the queue that stopped occasional noises on a low-end device where two were not enough (hajimehoshi/ebiten@4276e296), and at least two bursts, so a whole burst stays queued after any callback; the target is that and one read on top.onAudioReadyrecords the largest callback since the stream opened, andLoopReadkeeps two of those and a read queued, for a device whose callbacks outgrow its bursts. The target is capped at the fifo less one read.onAudioReadystays lock-free: the new state is atomics.Testing
internal/oboe/pace_test.goplays the real mux as a Bluetooth headset takes it: 1,920-frame callbacks every 40 ms, a 20 ms player buffer, and a source taking 2 ms a read. It runs in atesting/synctestbubble, so its result is the same on every machine: unpaced reads leave 38,400 of 76,800 samples silent, and paced reads none, with the device never short of a burst. A reader slower than real time fails it. For the bubble to end,internal/muxgainsMux.Stop, which stops the mux from reading the players' sources; oto's API is unchanged.On a Pixel 8 Pro (Android 16) with Sony WF-1000XM4 earbuds, the stream reports
burst=1920 bufferSize=18450, againstburst=96 bufferSize=192on the speaker. Marcus listened on the earbuds, on the speaker, and with the earbuds connected and disconnected while playing, in two apps built on gunim: clean throughout. Before this change, most of each read on the earbuds was silence. Builds that counted the samples the mux filled with silence, with this change's small reads:🤖 Generated with Claude Code