Skip to content

internal/oboe: read from Go in small, paced pieces, and queue for the device playing - #306

Merged
hajimehoshi merged 11 commits into
ebitengine:mainfrom
marrasen:android-bluetooth-reads
Oct 8, 2026
Merged

hajimehoshi merged 11 commits into
ebitengine:mainfrom
marrasen:android-bluetooth-reads

Conversation

@marrasen

@marrasen marrasen commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor

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. LoopRead read getBufferSizeInFrames() * 3 frames 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

  • A read from Go is 10 ms (sample_rate_ / 100) on every stream.
  • The reads are paced. After a device takes a large burst, LoopRead tops 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.go holds a pacer that spaces reads to at most twice real time, and the binding's Play wraps the read function in it.

Queueing

  • The fifo is made once, one second long at the context's sample rate, so it holds any device's queue without being resized.
  • How full LoopRead keeps 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. onAudioReady records the largest callback since the stream opened, and LoopRead keeps 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.
  • The shortest wait between reads is half a burst of the stream playing.

onAudioReady stays lock-free: the new state is atomics.

Testing

internal/oboe/pace_test.go plays 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 a testing/synctest bubble, 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/mux gains Mux.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, against burst=96 bufferSize=192 on 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:

Unpaced Paced
Earbuds, steady play 0 0
As the app starts 20,160 samples 0
As the earbuds reconnect 5,760 samples 0

🤖 Generated with Claude Code

… 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>
marrasen pushed a commit to marrasen/gunim that referenced this pull request Oct 4, 2026
…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>
@hajimehoshi

Copy link
Copy Markdown
Member

No issue filed. Sound through oto crackles on Android with a Bluetooth headset, and plays cleanly on the phone's speaker.

This seems a serious problem. Can you file an issue? Thanks

Comment thread internal/oboe/binding_android.cpp
@marrasen

marrasen commented Oct 5, 2026

Copy link
Copy Markdown
Contributor Author

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>
marrasen pushed a commit to marrasen/gunim that referenced this pull request Oct 5, 2026
…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>
@marrasen

marrasen commented Oct 5, 2026

Copy link
Copy Markdown
Contributor Author

Testing has been done with the fix on my phone.

This is the report from Claude:

After a callback frees room, LoopRead made several 480-frame reads back to back, faster than a player's source can refill its buffer.

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. internal/mux has a new Pacer that spaces reads so each comes at least half its own length after the last: at most twice real time, enough to catch up after a burst, with time between reads for the players to refill. The Android driver reads through it, and LoopRead is unchanged.

TestPacedReadsKeepASmallPlayerBufferPlaying covers the small-buffer case with the real mux and the timing above. It checks both ways: unpaced reads must leave silence, which shows the test meets the case, and paced reads must leave none. It passes with -race and with -cpu=1.

On a Pixel 8 Pro with Sony WF-1000XM4 earbuds, with a build that counted the samples the mux filled with silence:

Before After
Steady play 0 0
As the app starts 20,160 samples 0
As the earbuds reconnect 5,760 samples 0

Reply written by Claude (Anthropic), an AI agent, and posted at Marcus's request.

Marcus Johansson and others added 2 commits October 5, 2026 18:23
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>
@hajimehoshi

Copy link
Copy Markdown
Member

Now the test is failing.

@hajimehoshi

Copy link
Copy Markdown
Member

The failure is in the new test itself. On macOS with Go 1.25.x, 1 of the 10 -count=10 iterations failed (job):

pace_test.go:162: paced reads: the device found too little queued 3 times; want none

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 testing/synctest bubble instead? go.mod is at 1.25, so synctest.Test is available. Time in the bubble is virtual, so the result no longer depends on the machine. The test also finishes at once instead of taking 2 seconds, so the testing.Short() skip can go.

That needs one change. mux.New starts m.loop(), which never returns, and synctest.Test fails when a goroutine in the bubble is still blocked as the test ends:

panic: deadlock: main bubble goroutine has exited but blocked goroutines remain

So internal/mux needs a way to stop the loop: for example, a method that sets a flag under m.cond.L and broadcasts, with wait and loop returning once the flag is set. The test calls it from t.Cleanup. internal/mux is internal, so oto's API doesn't change.

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 -race and -cpu=1,4.

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>
@hajimehoshi

Copy link
Copy Markdown
Member

I'll continue this review tomorrow. Thank you for your work!

@hajimehoshi hajimehoshi left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread internal/oboe/binding_android.cpp Outdated
Comment thread internal/oboe/binding_android.cpp Outdated
Comment thread internal/oboe/binding_android.cpp Outdated
Comment thread internal/oboe/binding_android.cpp Outdated
Comment thread internal/mux/mux.go Outdated
Comment thread internal/oboe/pace.go Outdated
Comment thread internal/oboe/pace.go Outdated
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>
@marrasen marrasen changed the title Claude bug report: internal/oboe: crackling on Bluetooth headsets, from reads of over a second internal/oboe: read from Go in small, paced pieces, and queue for the device playing Oct 7, 2026
@marrasen

marrasen commented Oct 7, 2026

Copy link
Copy Markdown
Contributor Author

Thanks! All in 85f98e3, and the description and title are updated.

  • Long bursts: SetTargetLocked now keeps at least 2 * burst + read_frames_, and LoopRead's floor is 2 * max_callback_ + read_frames_, so a whole burst stays queued after any callback.
  • The margin's cap: the comment says it is capped at 80 ms, so a stream whose buffer is longer than about 13 ms keeps less than before.
  • fifo_: the comment says it holds one second at the context's sample rate, and only read_frames_ and tmp_ come from the first stream.
  • One cap: SetTargetLocked stores the target uncapped, and LoopRead caps it at the fifo's capacity less one read.
  • Stop and pacer: their doc comments now give only the contract; why the test stops its mux is at the call site.
  • newPacer: one field per line.

go test -shuffle=on -count=10 ./... passes, and the pacing test with -race -cpu=1,4 -count=10. The workflow passed on this commit in my fork, on all nine jobs: https://github.com/marrasen/oto/actions/runs/37576830930

Reply written by Claude (Anthropic)

@hajimehoshi hajimehoshi left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread internal/oboe/binding_android.cpp Outdated
Comment thread internal/oboe/binding_android.cpp Outdated
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>
Comment thread internal/oboe/binding_android.cpp Outdated
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>
Comment thread internal/oboe/binding_android.cpp Outdated
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 hajimehoshi left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few naming and comment points on 086d448, inline.

Review written by Claude (Anthropic), an AI agent, and posted at the user's request.

Comment thread internal/oboe/binding_android.cpp Outdated
Comment thread internal/oboe/binding_android.cpp Outdated
Comment thread internal/oboe/binding_android.cpp Outdated
Marcus Johansson and others added 2 commits October 7, 2026 19:23
…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>
@hajimehoshi

Copy link
Copy Markdown
Member

I'll take a look at this tomorrow. Thank you for your patience.

@hajimehoshi hajimehoshi left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, thanks!

@hajimehoshi
hajimehoshi merged commit 91d77a3 into ebitengine:main Oct 8, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Android: sound crackles through Bluetooth headsets

2 participants