Background
The Android backend compiles Oboe with AAudio disabled:
// Disable AAudio (hajimehoshi/ebiten#1634).
// AAudio doesn't care about plugging in/out of a headphone.
// See https://github.com/google/oboe/wiki/TechNote_Disconnect
// #cgo CXXFLAGS: -std=c++17 -DOBOE_ENABLE_AAUDIO=0
(internal/oboe/binding_android.go)
So AudioStreamBuilder::build() always constructs an AudioOutputStreamOpenSLES, and every Android playback path goes through OpenSL ES.
Motivation
1. OpenSL ES makes Android failures undiagnosable.
Oboe's OpenSL backend collapses every failure into a single Result::ErrorInternal, marked in its own source as // TODO convert error from SLES to OBOE. The four sites that produce it are:
| Site |
Failing operation |
oboe_opensles_AudioStreamOpenSLES_android.cpp:81 |
slCreateEngine / engine Realize / dlopen("libOpenSLES.so") |
oboe_opensles_AudioOutputStreamOpenSLES_android.cpp:133 |
output mixer open() |
oboe_opensles_AudioOutputStreamOpenSLES_android.cpp:225 |
CreateAudioPlayer, SetConfiguration, player Realize, GetInterface(PLAY), finishCommonOpen |
oboe_opensles_AudioOutputStreamOpenSLES_android.cpp:263 |
SetPlayState(SL_PLAYSTATE_PLAYING) |
This is why audio: audio error: oboe: Play failed: ErrorInternal, frequently reported from Samsung devices, carries no actionable information. AAudio returns specific codes (ErrorUnavailable, ErrorNoService, ErrorInvalidRate, ...) that would let these reports be triaged.
2. OpenSL ES is deprecated since API 30, receives no OEM bug fixes, and cannot reach the MMAP / exclusive low-latency path.
Why the original reason is weaker now
hajimehoshi/ebiten#1634 was filed in 2021 against a much older Oboe. Since then:
- Oboe installs a default error callback when the app doesn't specify one, specifically "so the stream gets closed and stopped" (
oboe_aaudio_AudioStreamAAudio_android.cpp), and holds a shared_ptr across the error thread via lockWeakThis(). oto already opens with the shared_ptr overload, so that protection applies.
- The TechNote_Disconnect wiki page was updated in Nov 2023 to state that
onErrorAfterClose() is already called on a thread Oboe creates, so the app does not need to create another one.
The disconnect behaviour itself is unchanged and is by design: an AAudio stream is bound to a device, and a routing change disconnects it. That is a handleable event, not a blocker.
What needs to be done
- Drop
-DOBOE_ENABLE_AAUDIO=0 from internal/oboe/binding_android.go.
- Make
Stream also implement oboe::AudioStreamErrorCallback and add ->setErrorCallback(this) to the builder.
- Reopen and start a new stream in
onErrorAfterClose() when the result is Result::ErrorDisconnected. No extra thread is needed.
- Split
Stream::Play() into a one-time part (creating the reader thread) and a repeatable open-and-start part. The reconnect path must not spawn a second Loop thread.
- Add a mutex around
stream_. It is currently accessed with no lock at all, and after this change onErrorAfterClose() runs on an Oboe thread concurrently with Suspend()/Resume() from Go.
- Clear
buf_ on reconnect, otherwise up to three buffers of stale audio are replayed after the switch.
- Handle a changed burst size.
Loop() sizes tmp once from getBufferSizeInFrames() at thread creation; the new device may report a different value.
- Bounded retry with backoff when the reopen fails, since the new device may not be ready immediately.
- Stop latching the error permanently.
newContext stores the first error in context.err and never retries, and NewContext can only be called once per process, so today a single transient failure means permanently silent audio. A disconnect followed by a successful reopen must not surface as an error at all.
Risk
On Android P and some early Q builds, the disconnect callback did not fire at all, so a reopen handler would never run. This was a platform bug, not an Oboe or app bug; it was fixed and is now covered by QA (google/oboe#893). The other reports of it (google/oboe#381, google/oboe#1350) are also closed, and #1350 turned out to be a P build misreported as Android 11. There is no confirmed report on R or later.
If a fallback is still wanted, the option that fits oto is stall detection: if the data callback has not run for some multiple of the buffer duration while the stream is Started, treat it as disconnected and reopen. This needs no Java Context and also covers silent stalls that are not disconnects. The Intent.ACTION_HEADSET_PLUG receiver suggested by the Oboe tech note is not directly available here, since oto has no JNI layer.
Suggested rollout
Enable AAudio behind an opt-in option first, so a Samsung reporter can be asked to try it and confirm whether ErrorInternal becomes a specific error code, before it becomes the default path.
Filed by Claude (Claude Code), on behalf of @hajimehoshi.
Background
The Android backend compiles Oboe with AAudio disabled:
(
internal/oboe/binding_android.go)So
AudioStreamBuilder::build()always constructs anAudioOutputStreamOpenSLES, and every Android playback path goes through OpenSL ES.Motivation
1. OpenSL ES makes Android failures undiagnosable.
Oboe's OpenSL backend collapses every failure into a single
Result::ErrorInternal, marked in its own source as// TODO convert error from SLES to OBOE. The four sites that produce it are:oboe_opensles_AudioStreamOpenSLES_android.cpp:81slCreateEngine/ engineRealize/dlopen("libOpenSLES.so")oboe_opensles_AudioOutputStreamOpenSLES_android.cpp:133open()oboe_opensles_AudioOutputStreamOpenSLES_android.cpp:225CreateAudioPlayer,SetConfiguration, playerRealize,GetInterface(PLAY),finishCommonOpenoboe_opensles_AudioOutputStreamOpenSLES_android.cpp:263SetPlayState(SL_PLAYSTATE_PLAYING)This is why
audio: audio error: oboe: Play failed: ErrorInternal, frequently reported from Samsung devices, carries no actionable information. AAudio returns specific codes (ErrorUnavailable,ErrorNoService,ErrorInvalidRate, ...) that would let these reports be triaged.2. OpenSL ES is deprecated since API 30, receives no OEM bug fixes, and cannot reach the MMAP / exclusive low-latency path.
Why the original reason is weaker now
hajimehoshi/ebiten#1634was filed in 2021 against a much older Oboe. Since then:oboe_aaudio_AudioStreamAAudio_android.cpp), and holds ashared_ptracross the error thread vialockWeakThis(). oto already opens with theshared_ptroverload, so that protection applies.onErrorAfterClose()is already called on a thread Oboe creates, so the app does not need to create another one.The disconnect behaviour itself is unchanged and is by design: an AAudio stream is bound to a device, and a routing change disconnects it. That is a handleable event, not a blocker.
What needs to be done
-DOBOE_ENABLE_AAUDIO=0frominternal/oboe/binding_android.go.Streamalso implementoboe::AudioStreamErrorCallbackand add->setErrorCallback(this)to the builder.onErrorAfterClose()when the result isResult::ErrorDisconnected. No extra thread is needed.Stream::Play()into a one-time part (creating the reader thread) and a repeatable open-and-start part. The reconnect path must not spawn a secondLoopthread.stream_. It is currently accessed with no lock at all, and after this changeonErrorAfterClose()runs on an Oboe thread concurrently withSuspend()/Resume()from Go.buf_on reconnect, otherwise up to three buffers of stale audio are replayed after the switch.Loop()sizestmponce fromgetBufferSizeInFrames()at thread creation; the new device may report a different value.newContextstores the first error incontext.errand never retries, andNewContextcan only be called once per process, so today a single transient failure means permanently silent audio. A disconnect followed by a successful reopen must not surface as an error at all.Risk
On Android P and some early Q builds, the disconnect callback did not fire at all, so a reopen handler would never run. This was a platform bug, not an Oboe or app bug; it was fixed and is now covered by QA (google/oboe#893). The other reports of it (google/oboe#381, google/oboe#1350) are also closed, and #1350 turned out to be a P build misreported as Android 11. There is no confirmed report on R or later.
If a fallback is still wanted, the option that fits oto is stall detection: if the data callback has not run for some multiple of the buffer duration while the stream is
Started, treat it as disconnected and reopen. This needs no JavaContextand also covers silent stalls that are not disconnects. TheIntent.ACTION_HEADSET_PLUGreceiver suggested by the Oboe tech note is not directly available here, since oto has no JNI layer.Suggested rollout
Enable AAudio behind an opt-in option first, so a Samsung reporter can be asked to try it and confirm whether
ErrorInternalbecomes a specific error code, before it becomes the default path.Filed by Claude (Claude Code), on behalf of @hajimehoshi.