Skip to content

Enable AAudio on Android and handle stream disconnection #284

Description

@hajimehoshi

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

  1. Drop -DOBOE_ENABLE_AAUDIO=0 from internal/oboe/binding_android.go.
  2. Make Stream also implement oboe::AudioStreamErrorCallback and add ->setErrorCallback(this) to the builder.
  3. Reopen and start a new stream in onErrorAfterClose() when the result is Result::ErrorDisconnected. No extra thread is needed.
  4. 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.
  5. 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.
  6. Clear buf_ on reconnect, otherwise up to three buffers of stale audio are replayed after the switch.
  7. Handle a changed burst size. Loop() sizes tmp once from getBufferSizeInFrames() at thread creation; the new device may report a different value.
  8. Bounded retry with backoff when the reopen fails, since the new device may not be ready immediately.
  9. 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.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions