The Android audio plane trusted AAudio, so a TV that opened a dead stream was silent all session #214

Merged
enricobuehler merged 2 commits from worktree-android-aaudio-shield-silence into main 2026-08-14 06:22:36 +00:00
5 changed files with 756 additions and 244 deletions
+50
View File
@@ -14,6 +14,56 @@ with the version table of the release you are moving to, then read **Breaking ch
## v0.28.1 — in development
### Android — the audio plane trusted AAudio, and a TV box that opened a stream it never played was silent for the session
🛑 **Reported from the field: no audio at all on an NVIDIA Shield Android TV, stereo, with the same
host and settings that play fine on an Apple TV.** Video unaffected. Turning off the client's
low-latency mode — which is what gates the forced HDMI mode switch and the `usage=Game` tagging —
changed nothing.
The Android client opens AAudio directly (the Apple client goes through AVAudioEngine, which
reconfigures itself on a route change; that difference is why this was Android-only). Opening
AAudio is a negotiation with a vendor HAL, and this plane treated it as a formality: one Exclusive
attempt, one Shared retry, and everything after the open taken on trust. **Three distinct failures
all presented as "the app has no sound" behind a healthy-looking log**, and none of them was
detected:
- **A configuration that opens but routes nowhere.** Nothing ever checked that the device actually
pulled a sample, so the decode thread would happily decode Opus into a dead stream forever.
- **`request_start` failing.** The old code gave up on the spot instead of trying anything else, so
one unhappy configuration disabled audio for the whole session.
- **A disconnect.** By AAudio's contract a disconnected stream is dead and the only recovery is
close + open a new one. The error callback logged a warning and did nothing else — so an HDMI
mode switch, an AVR re-handshake or any route change meant silence for the rest of the session.
On a TV that is not a rare event: the client itself drives an HDMI mode switch on the video
plane, and the platform's own match-content-frame-rate setting drives more.
The open now walks a **ladder**, every rung has to **prove the device is pulling** before it is
accepted, and a **supervisor** owns the plane for the session and reopens it when the device goes
away (bounded retries across the settling time of a route change, so a reopen landing mid-switch
does not permanently disable audio). The granted rate/channel-count/format are checked against what
was asked for rather than assumed — the realtime callback casts AAudio's buffer to `f32` and writes
`num_frames × channels` of them, so a HAL that disagreed was an out-of-bounds write on the audio
thread, not merely a mistuning.
**Behaviour change on TV boxes: they now start at Shared instead of Exclusive.** Exclusive is
MMAP, the lowest-latency path AAudio has, and the one rung whose routing cannot be verified from
inside the process. The latency it buys here was never actually banked — the jitter-ring depths are
unchanged from the Shared-only era (`JitterTuning::AAUDIO` still primes at 25 ms) — so on a
mains-powered HDMI box the few ms are worth less than not betting the audio plane on it. Phones,
tablets and handhelds are unchanged and still try Exclusive first. If no rung proves itself, the
first one that opened and started is used anyway: the watchdog must never be able to turn working
audio into no audio.
**Embedder-visible:** `NativeBridge.nativeStartAudio` takes a third argument, `isTv`
(`FEATURE_LEANBACK`, the same source the video plane already used).
Three new sysprops bisect all of it on a device that cannot be handed a custom build, alongside the
existing `debug.punktfunk.no_av_sync`: `debug.punktfunk.audio_sharing` (`exclusive`|`shared`),
`debug.punktfunk.audio_perf` (`lowlatency`|`none`) and `debug.punktfunk.audio_reopen` (`0` pins the
old give-up-on-disconnect behaviour). A stream that stops taking samples after it started now says
so at `error` level instead of looking exactly like an app with no sound.
### punktfunk-gamescope `+pfhdr7` — a lingered session no longer dies of its own capture teardown
🛑 **On client disconnect the host keeps the headless gamescope alive so a reconnect resumes the
@@ -816,7 +816,7 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
?: (runCatching { context.display }.getOrNull()?.refreshRate ?: 0f)
.roundToInt(),
)
NativeBridge.nativeStartAudio(handle, lowLatencyMode)
NativeBridge.nativeStartAudio(handle, lowLatencyMode, isTv)
if (micWanted) {
val sessionId =
NativeBridge.nativeStartMic(handle, initialSettings.echoCancel)
@@ -302,12 +302,17 @@ object NativeBridge {
external fun nativeSetVideoStatsEnabled(handle: Long, enabled: Boolean)
/**
* Start hostclient audio: Opus decode jitter ring AAudio (LowLatency), all in Rust.
* Start hostclient audio: Opus decode jitter ring AAudio, all in Rust.
* [lowLatencyMode] (the experimental toggle) additionally tags the stream usage=Game for the
* HAL's game-audio routing. No-op if already started. Best-effort a failure leaves video
* streaming.
*
* [isTv] steers the AAudio open ladder: a TV box starts at Shared rather than betting the
* audio plane on an Exclusive/MMAP path whose routing we cannot verify from inside the
* process. Passed from `FEATURE_LEANBACK` (same source as [nativeStartVideo]) because the
* native side's own `ro.build.characteristics` check is not answered by every TV device.
*/
external fun nativeStartAudio(handle: Long, lowLatencyMode: Boolean)
external fun nativeStartAudio(handle: Long, lowLatencyMode: Boolean, isTv: Boolean)
/** Stop + join the audio thread and close AAudio, without closing the session. No-op on `0`. */
external fun nativeStopAudio(handle: Long)
File diff suppressed because it is too large Load Diff
+6 -5
View File
@@ -357,10 +357,10 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetVideoSta
})
}
/// `NativeBridge.nativeStartAudio(handle, lowLatencyMode)` — start the Opus→AAudio playback thread.
/// `lowLatencyMode` (the experimental toggle) tags the stream usage=Game for the HAL's game-audio
/// routing. No-op if already started or on a `0` handle. Best-effort: a failure leaves video
/// streaming.
/// `NativeBridge.nativeStartAudio(handle, lowLatencyMode, isTv)` — start the Opus→AAudio playback
/// supervisor. `lowLatencyMode` (the experimental toggle) tags the stream usage=Game for the HAL's
/// game-audio routing; `isTv` steers the AAudio open ladder (see `crate::audio::open_ladder`).
/// No-op if already started or on a `0` handle. Best-effort: a failure leaves video streaming.
#[cfg(target_os = "android")]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartAudio(
@@ -368,6 +368,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartAudio(
_this: JObject,
handle: jlong,
low_latency_mode: jboolean,
is_tv: jboolean,
) {
if handle == 0 {
return;
@@ -378,7 +379,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartAudio(
if guard.is_some() {
return; // already playing
}
match crate::audio::AudioPlayback::start(h.client.clone(), low_latency_mode) {
match crate::audio::AudioPlayback::start(h.client.clone(), low_latency_mode, is_tv) {
Some(p) => *guard = Some(p),
None => log::error!("nativeStartAudio: playback init failed (video unaffected)"),
}