Field report: no audio at all on an NVIDIA Shield Android TV, stereo, same host and settings that play fine on an Apple TV. Video unaffected. Turning off the client's low-latency mode — which gates the forced HDMI mode switch and the usage=Game tagging, the only two things that toggle controls — changed nothing, so both are exonerated.
This client opens AAudio directly, where the Apple one goes through AVAudioEngine and gets route-change handling for free. That difference is why this was Android-only.
What was wrong
Opening AAudio is a negotiation with a vendor HAL, and this plane treated it as a formality: one Exclusive attempt, one Shared retry, everything after the open taken on trust. Three distinct failures all presented as "the app has no sound" behind a perfectly ordinary log line, and none was detected:
A configuration that opens but routes nowhere. Nothing ever checked that the device pulled a single sample, so the decode thread fed Opus into a dead stream indefinitely.
request_start failing. We gave up on the spot rather than trying anything else, so one unhappy configuration disabled audio for the whole session.
A disconnect. By AAudio's contract the stream is then dead and the only recovery is close + open a new one; the error callback logged a warning and did nothing. On a TV that is not rare — this client itself drives an HDMI mode switch on the video plane, and the platform's own match-content-frame-rate setting drives more.
What changed
The open walks a ladder; every rung must prove the device is actually pulling before it is accepted.
A supervisor owns the plane for the session and reopens on disconnect, with bounded retries across a route change's settling time so a reopen landing mid-switch cannot permanently disable audio.
Granted rate/channels/format are checked rather than assumed. Not a likely root cause (the NDK contract is honoured-or-open-fails), but 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.
⚠ Behaviour change: TV boxes now start at Shared, not 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 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: a 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) — ro.build.characteristics is not answered by every TV device, so the native-only check would have silently missed the Shield.
Field levers, no rebuild
Alongside the existing debug.punktfunk.no_av_sync:
adb shell setprop debug.punktfunk.audio_sharing shared # or exclusive
adb shell setprop debug.punktfunk.audio_perf none # or lowlatency
adb shell setprop debug.punktfunk.audio_reopen 0 # old give-up-on-disconnect
A stream that stops taking samples after it started now logs at error level instead of looking exactly like an app with no sound.
Verification
cargo fmt; clippy -D warnings clean on arm64-v8a and armeabi-v7a; :app:compileDebugKotlin and :app:testDebugUnitTest BUILD SUCCESSFUL; the API-28 JNI import floor check clean across all three ABIs.
🛑Not verified on a Shield — no such device here, and the root cause is still unconfirmed. All three defects fixed are real independently of which one is biting the reporter. The sysprops above make the next field round-trip cost one setprop instead of a build.
Field report: **no audio at all on an NVIDIA Shield Android TV**, stereo, same host and settings that play fine on an Apple TV. Video unaffected. Turning off the client's low-latency mode — which gates the forced HDMI mode switch and the `usage=Game` tagging, the only two things that toggle controls — changed nothing, so both are exonerated.
This client opens AAudio directly, where the Apple one goes through AVAudioEngine and gets route-change handling for free. That difference is why this was Android-only.
## What was wrong
Opening AAudio is a negotiation with a vendor HAL, and this plane treated it as a formality: one Exclusive attempt, one Shared retry, everything after the open taken on trust. **Three distinct failures all presented as "the app has no sound" behind a perfectly ordinary log line**, and none was detected:
- **A configuration that opens but routes nowhere.** Nothing ever checked that the device pulled a single sample, so the decode thread fed Opus into a dead stream indefinitely.
- **`request_start` failing.** We gave up on the spot rather than trying anything else, so one unhappy configuration disabled audio for the whole session.
- **A disconnect.** By AAudio's contract the stream is then dead and the only recovery is close + open a new one; the error callback logged a warning and did nothing. On a TV that is not rare — this client itself drives an HDMI mode switch on the video plane, and the platform's own match-content-frame-rate setting drives more.
## What changed
- The open walks a **ladder**; every rung must **prove the device is actually pulling** before it is accepted.
- A **supervisor** owns the plane for the session and **reopens on disconnect**, with bounded retries across a route change's settling time so a reopen landing mid-switch cannot permanently disable audio.
- Granted rate/channels/format are **checked rather than assumed**. Not a likely root cause (the NDK contract is honoured-or-open-fails), but 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.
### ⚠ Behaviour change: TV boxes now start at Shared, not 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 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: a 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) — `ro.build.characteristics` is not answered by every TV device, so the native-only check would have silently missed the Shield.
## Field levers, no rebuild
Alongside the existing `debug.punktfunk.no_av_sync`:
```
adb shell setprop debug.punktfunk.audio_sharing shared # or exclusive
adb shell setprop debug.punktfunk.audio_perf none # or lowlatency
adb shell setprop debug.punktfunk.audio_reopen 0 # old give-up-on-disconnect
```
A stream that stops taking samples after it started now logs at `error` level instead of looking exactly like an app with no sound.
## Verification
`cargo fmt`; clippy `-D warnings` clean on `arm64-v8a` and `armeabi-v7a`; `:app:compileDebugKotlin` and `:app:testDebugUnitTest` BUILD SUCCESSFUL; the API-28 JNI import floor check clean across all three ABIs.
🛑 **Not verified on a Shield — no such device here, and the root cause is still unconfirmed.** All three defects fixed are real independently of which one is biting the reporter. The sysprops above make the next field round-trip cost one `setprop` instead of a build.
Field report: no audio at all on an NVIDIA Shield Android TV, stereo, same
host and settings that play fine on an Apple TV. Video unaffected. Turning
off low-latency mode — which gates the forced HDMI mode switch and the
usage=Game tagging, the two things that toggle controls — changed nothing.
This client opens AAudio directly, where the Apple one goes through
AVAudioEngine and gets route-change handling for free; that 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,
everything after the open taken on trust. Three separate failures all came
out as "the app has no sound" behind a perfectly ordinary log line:
- a configuration that opens but routes nowhere — nothing ever checked
that the device pulled a single sample, so the decode thread fed Opus
into a dead stream indefinitely;
- request_start failing — we gave up on the spot rather than trying
anything else, so one unhappy config disabled audio for the session;
- a disconnect — by AAudio's contract the stream is then DEAD and the
only recovery is close + open a new one, but the error callback logged
a warning and did nothing. On a TV that is not rare: this client 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 must 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 — with bounded retries across the
settling time of a route change, so a reopen landing mid-switch cannot
permanently disable audio. Granted rate/channels/format are checked 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 just a mistuning.
TV boxes now start at Shared. 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 was never banked, since the ring depths
are unchanged from the Shared-only era (AAUDIO still primes at 25 ms). On a
mains-powered HDMI box that trade is not worth betting the audio plane on.
Phones keep Exclusive first. If no rung proves itself the first one that
opened and started is used anyway — a watchdog must never be able to turn
working audio into no audio.
nativeStartAudio takes isTv (FEATURE_LEANBACK, the source the video plane
already used) because ro.build.characteristics is not answered by every TV.
debug.punktfunk.audio_sharing / audio_perf / audio_reopen bisect all of it
with setprop, for the device that reports silence and cannot be handed a
custom build. 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.
Not verified on a Shield — no such device here.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Field report: no audio at all on an NVIDIA Shield Android TV, stereo, same host and settings that play fine on an Apple TV. Video unaffected. Turning off the client's low-latency mode — which gates the forced HDMI mode switch and the
usage=Gametagging, the only two things that toggle controls — changed nothing, so both are exonerated.This client opens AAudio directly, where the Apple one goes through AVAudioEngine and gets route-change handling for free. That difference is why this was Android-only.
What was wrong
Opening AAudio is a negotiation with a vendor HAL, and this plane treated it as a formality: one Exclusive attempt, one Shared retry, everything after the open taken on trust. Three distinct failures all presented as "the app has no sound" behind a perfectly ordinary log line, and none was detected:
request_startfailing. We gave up on the spot rather than trying anything else, so one unhappy configuration disabled audio for the whole session.What changed
f32and writesnum_frames × channelsof them, so a HAL that disagreed was an out-of-bounds write on the audio thread.⚠ Behaviour change: TV boxes now start at Shared, not 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::AAUDIOstill primes at 25 ms) — so on a mains-powered HDMI box the few ms are worth less than 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: a watchdog must never be able to turn working audio into no audio.
⚠ Embedder-visible
NativeBridge.nativeStartAudiotakes a third argument,isTv(FEATURE_LEANBACK, the same source the video plane already used) —ro.build.characteristicsis not answered by every TV device, so the native-only check would have silently missed the Shield.Field levers, no rebuild
Alongside the existing
debug.punktfunk.no_av_sync:A stream that stops taking samples after it started now logs at
errorlevel instead of looking exactly like an app with no sound.Verification
cargo fmt; clippy-D warningsclean onarm64-v8aandarmeabi-v7a;:app:compileDebugKotlinand:app:testDebugUnitTestBUILD SUCCESSFUL; the API-28 JNI import floor check clean across all three ABIs.🛑 Not verified on a Shield — no such device here, and the root cause is still unconfirmed. All three defects fixed are real independently of which one is biting the reporter. The sysprops above make the next field round-trip cost one
setpropinstead of a build.Field report: no audio at all on an NVIDIA Shield Android TV, stereo, same host and settings that play fine on an Apple TV. Video unaffected. Turning off low-latency mode — which gates the forced HDMI mode switch and the usage=Game tagging, the two things that toggle controls — changed nothing. This client opens AAudio directly, where the Apple one goes through AVAudioEngine and gets route-change handling for free; that 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, everything after the open taken on trust. Three separate failures all came out as "the app has no sound" behind a perfectly ordinary log line: - a configuration that opens but routes nowhere — nothing ever checked that the device pulled a single sample, so the decode thread fed Opus into a dead stream indefinitely; - request_start failing — we gave up on the spot rather than trying anything else, so one unhappy config disabled audio for the session; - a disconnect — by AAudio's contract the stream is then DEAD and the only recovery is close + open a new one, but the error callback logged a warning and did nothing. On a TV that is not rare: this client 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 must 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 — with bounded retries across the settling time of a route change, so a reopen landing mid-switch cannot permanently disable audio. Granted rate/channels/format are checked 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 just a mistuning. TV boxes now start at Shared. 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 was never banked, since the ring depths are unchanged from the Shared-only era (AAUDIO still primes at 25 ms). On a mains-powered HDMI box that trade is not worth betting the audio plane on. Phones keep Exclusive first. If no rung proves itself the first one that opened and started is used anyway — a watchdog must never be able to turn working audio into no audio. nativeStartAudio takes isTv (FEATURE_LEANBACK, the source the video plane already used) because ro.build.characteristics is not answered by every TV. debug.punktfunk.audio_sharing / audio_perf / audio_reopen bisect all of it with setprop, for the device that reports silence and cannot be handed a custom build. 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. Not verified on a Shield — no such device here.