fix(player): offer the TrueHD MAT carrier on API 29-32 routes

Carrier-or-decode gated the carrier on getDirectPlaybackSupport, which only
exists on API 33, so every older route force-decoded TrueHD - including
routes that bitstreamed it before the carrier existed. The #1863 Fire TV
Stick 4K Max is Fire OS 8 (API 30): its HDMI route advertises raw TrueHD
and IEC 61937 at 8 channels, 2.12.1 passed TrueHD through, and 2.13.0 hands
the same stream to the FFmpeg decoder. The Shield is API 30 as well.

API 29-32 now asks AudioTrack.isDirectPlaybackSupported about the exact
192kHz/7.1 IEC tuple before offering the carrier. It is coarser than the
API 33 probe - it cannot tell bitstream from offload - but an IEC 61937
track is PCM-shaped by definition, so direct support means the route
carries the frames. getMinBufferSize stays as the precondition on every
tier, and a route that still lies fails AudioTrack initialisation, which
the audio recovery path already answers by blocking direct output and
force-decoding in place. Below API 29 nothing can vouch for the tuple, so
the carrier is still not offered and TrueHD decodes as before.

The tier decision is split from the platform probes so it is unit-testable;
each probe is consulted only on the tiers where its API exists.
This commit is contained in:
edde746
2026-08-10 22:54:17 +02:00
parent d19ec625cd
commit aff6b6576f
4 changed files with 150 additions and 36 deletions
@@ -203,7 +203,7 @@ class TrueHdSpeedTransitionTest {
@Test
fun aRateFamilyMismatchFallsBackToTheDecoderInsteadOfGoingSilent() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
if (!supportsTrueHdMatCarrier(context)) {
if (!supportsTrueHdMatCarrier()) {
Log.i(TAG, "==== MISMATCH SKIPPED: device has no carrier route ====")
return
}
@@ -7,6 +7,7 @@ import android.media.AudioTrack
import android.os.Build
import android.util.Log
import androidx.annotation.OptIn
import androidx.annotation.RequiresApi
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.MimeTypes
@@ -99,41 +100,88 @@ internal fun supportedMpvSpdifCodecs(context: Context): String {
* layer about the encoding, which on the boxes measured for this issue answers "TrueHD is
* offload-capable" and says nothing about whether a raw TrueHD track will ever drain.
*
* Both are consulted: `getMinBufferSize` proves a track can be built, and, where the API exists,
* `getDirectPlaybackSupport` proves the route will actually bitstream it rather than silently
* decode or wedge.
* Both are consulted: `getMinBufferSize` proves a track can be built, and a direct-playback oracle
* proves the route will actually bitstream it rather than silently decode or wedge. Sizing alone is
* not sufficient — on a Shield it answers yes for this tuple and the AudioTrack then fails to
* initialise.
*
* The oracle is tiered by what the platform offers:
* - API 33+: `getDirectPlaybackSupport`, whose bitstream flag also rules out offload-only answers.
* - API 2932: `AudioTrack.isDirectPlaybackSupported` for the same tuple. Coarser — it cannot tell
* bitstream from offload — but an IEC 61937 track is PCM-shaped by definition, so direct support
* for it means the route carries the frames. Fire OS 8 (API 30) devices bitstream TrueHD this way
* and lost passthrough entirely under an API 33 gate (#1863). A route that still lies here fails
* AudioTrack initialisation, which the audio recovery path answers by force-decoding.
* - Below API 29 there is no oracle at all, so the carrier is not offered and TrueHD decodes as
* before.
*/
internal fun supportsTrueHdMatCarrier(context: Context): Boolean {
// getMinBufferSize alone is not sufficient. On a Shield it answers yes for the 192kHz/7.1 IEC
// tuple and the AudioTrack then fails to initialise; it reports that a buffer can be sized, not
// that the route will carry the format. Without getDirectPlaybackSupport there is no way to tell
// the two apart, so below API 33 the carrier is not offered and TrueHD decodes as before.
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return false
val rate = TrueHdMatPacker.CARRIER_SAMPLE_RATE
val mask = AudioFormat.CHANNEL_OUT_7POINT1_SURROUND
val sizedOk = try {
AudioTrack.getMinBufferSize(rate, mask, AudioFormat.ENCODING_IEC61937) > 0
} catch (error: Exception) {
false
internal fun supportsTrueHdMatCarrier(): Boolean = trueHdMatCarrierSupported(
sdkInt = Build.VERSION.SDK_INT,
canSizeCarrierBuffer = {
try {
AudioTrack.getMinBufferSize(
TrueHdMatPacker.CARRIER_SAMPLE_RATE,
AudioFormat.CHANNEL_OUT_7POINT1_SURROUND,
AudioFormat.ENCODING_IEC61937
) > 0
} catch (error: Exception) {
false
}
},
// The SDK_INT guards repeat trueHdMatCarrierSupported's tiering only because lint's NewApi
// check cannot see through the injected lambdas.
bitstreamSupported = {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && iecCarrierBitstreamSupported()
},
directPlaybackSupported = {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && iecCarrierDirectPlaybackSupported()
}
if (!sizedOk) return false
)
return try {
val audioAttributes = AudioAttributes.Builder()
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
.setUsage(C.USAGE_MEDIA)
.build()
.getPlatformAudioAttributes()
val probe = AudioFormat.Builder()
.setEncoding(AudioFormat.ENCODING_IEC61937)
.setChannelMask(mask)
.setSampleRate(rate)
.build()
val support = AudioManager.getDirectPlaybackSupport(probe, audioAttributes)
(support and AudioManager.DIRECT_PLAYBACK_BITSTREAM_SUPPORTED) != 0
} catch (error: Exception) {
Log.w(TAG, "IEC 61937 carrier probe failed; not offering the TrueHD carrier", error)
false
}
/**
* [supportsTrueHdMatCarrier] with the platform probes injected. Probes are only consulted on the
* API tiers where they exist: [bitstreamSupported] (`getDirectPlaybackSupport`) on 33+ and
* [directPlaybackSupported] (`AudioTrack.isDirectPlaybackSupported`) on 2932.
*/
internal fun trueHdMatCarrierSupported(
sdkInt: Int,
canSizeCarrierBuffer: () -> Boolean,
bitstreamSupported: () -> Boolean,
directPlaybackSupported: () -> Boolean
): Boolean = when {
sdkInt < Build.VERSION_CODES.Q -> false
!canSizeCarrierBuffer() -> false
sdkInt >= Build.VERSION_CODES.TIRAMISU -> bitstreamSupported()
else -> directPlaybackSupported()
}
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
private fun iecCarrierBitstreamSupported(): Boolean = try {
val support = AudioManager.getDirectPlaybackSupport(iecCarrierProbeFormat(), movieAudioAttributes())
(support and AudioManager.DIRECT_PLAYBACK_BITSTREAM_SUPPORTED) != 0
} catch (error: Exception) {
Log.w(TAG, "IEC 61937 carrier probe failed; not offering the TrueHD carrier", error)
false
}
@RequiresApi(Build.VERSION_CODES.Q)
@Suppress("DEPRECATION") // Deprecated in favour of the API 33 probe the tier above uses.
private fun iecCarrierDirectPlaybackSupported(): Boolean = try {
AudioTrack.isDirectPlaybackSupported(iecCarrierProbeFormat(), movieAudioAttributes())
} catch (error: Exception) {
Log.w(TAG, "IEC 61937 carrier probe failed; not offering the TrueHD carrier", error)
false
}
/** The exact tuple the carrier's `AudioTrack` is built with; see [PlezyRenderersFactory]. */
private fun iecCarrierProbeFormat(): AudioFormat = AudioFormat.Builder()
.setEncoding(AudioFormat.ENCODING_IEC61937)
.setChannelMask(AudioFormat.CHANNEL_OUT_7POINT1_SURROUND)
.setSampleRate(TrueHdMatPacker.CARRIER_SAMPLE_RATE)
.build()
private fun movieAudioAttributes(): android.media.AudioAttributes = AudioAttributes.Builder()
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
.setUsage(C.USAGE_MEDIA)
.build()
.getPlatformAudioAttributes()
@@ -208,7 +208,7 @@ class PlezyRenderersFactory(context: Context) : DefaultRenderersFactory(context)
return TrueHdCarrierSink(
defaultSink = processedSink,
carrierSink = buildCarrierSink(context, bufferSizeProvider),
carrierRouteAvailable = { supportsTrueHdMatCarrier(context) },
carrierRouteAvailable = { supportsTrueHdMatCarrier() },
directOutputBlocked = { format -> shouldBlockDirectAudioOutput?.invoke(format) == true },
log = audioDiagnosticsLogger
).also { trueHdCarrierSink = it }
@@ -82,4 +82,70 @@ class AudioOutputPolicyTest {
fun spdifListIsEmptyForPcmOnlyRoutes() {
assertEquals("", mpvSpdifCodecs { false })
}
@Test
fun carrierIsNeverOfferedBelowApi29() {
// No direct-playback oracle exists there, and getMinBufferSize alone is known to lie
// (a Shield sizes the tuple, then the AudioTrack fails to initialise).
assertFalse(
trueHdMatCarrierSupported(
sdkInt = 28,
canSizeCarrierBuffer = { true },
bitstreamSupported = { true },
directPlaybackSupported = { true }
)
)
}
@Test
fun carrierRequiresASizableBufferOnEveryTier() {
for (sdkInt in intArrayOf(29, 30, 32, 33, 34)) {
assertFalse(
"api $sdkInt",
trueHdMatCarrierSupported(
sdkInt = sdkInt,
canSizeCarrierBuffer = { false },
bitstreamSupported = { true },
directPlaybackSupported = { true }
)
)
}
}
@Test
fun carrierOnApi29To32FollowsTheDirectPlaybackProbe() {
// Fire OS 8 (API 30) bitstreams TrueHD over this route; an API 33 gate force-decoded it (#1863).
for (supported in booleanArrayOf(true, false)) {
for (sdkInt in intArrayOf(29, 30, 32)) {
assertEquals(
"api $sdkInt supported=$supported",
supported,
trueHdMatCarrierSupported(
sdkInt = sdkInt,
canSizeCarrierBuffer = { true },
bitstreamSupported = { throw AssertionError("getDirectPlaybackSupport does not exist below API 33") },
directPlaybackSupported = { supported }
)
)
}
}
}
@Test
fun carrierOnApi33UsesTheBitstreamProbe() {
// getDirectPlaybackSupport distinguishes bitstream from offload-only; the coarser API 29
// probe must not shadow it where the platform can answer precisely.
for (supported in booleanArrayOf(true, false)) {
assertEquals(
"supported=$supported",
supported,
trueHdMatCarrierSupported(
sdkInt = 33,
canSizeCarrierBuffer = { true },
bitstreamSupported = { supported },
directPlaybackSupported = { throw AssertionError("API 29 probe must not be consulted on API 33+") }
)
)
}
}
}