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 @Test
fun aRateFamilyMismatchFallsBackToTheDecoderInsteadOfGoingSilent() { fun aRateFamilyMismatchFallsBackToTheDecoderInsteadOfGoingSilent() {
val context = InstrumentationRegistry.getInstrumentation().targetContext val context = InstrumentationRegistry.getInstrumentation().targetContext
if (!supportsTrueHdMatCarrier(context)) { if (!supportsTrueHdMatCarrier()) {
Log.i(TAG, "==== MISMATCH SKIPPED: device has no carrier route ====") Log.i(TAG, "==== MISMATCH SKIPPED: device has no carrier route ====")
return return
} }
@@ -7,6 +7,7 @@ import android.media.AudioTrack
import android.os.Build import android.os.Build
import android.util.Log import android.util.Log
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.annotation.RequiresApi
import androidx.media3.common.AudioAttributes import androidx.media3.common.AudioAttributes
import androidx.media3.common.C import androidx.media3.common.C
import androidx.media3.common.MimeTypes 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 * 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. * 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, * Both are consulted: `getMinBufferSize` proves a track can be built, and a direct-playback oracle
* `getDirectPlaybackSupport` proves the route will actually bitstream it rather than silently * proves the route will actually bitstream it rather than silently decode or wedge. Sizing alone is
* decode or wedge. * 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 { internal fun supportsTrueHdMatCarrier(): Boolean = trueHdMatCarrierSupported(
// getMinBufferSize alone is not sufficient. On a Shield it answers yes for the 192kHz/7.1 IEC sdkInt = Build.VERSION.SDK_INT,
// tuple and the AudioTrack then fails to initialise; it reports that a buffer can be sized, not canSizeCarrierBuffer = {
// that the route will carry the format. Without getDirectPlaybackSupport there is no way to tell try {
// the two apart, so below API 33 the carrier is not offered and TrueHD decodes as before. AudioTrack.getMinBufferSize(
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return false TrueHdMatPacker.CARRIER_SAMPLE_RATE,
AudioFormat.CHANNEL_OUT_7POINT1_SURROUND,
val rate = TrueHdMatPacker.CARRIER_SAMPLE_RATE AudioFormat.ENCODING_IEC61937
val mask = AudioFormat.CHANNEL_OUT_7POINT1_SURROUND ) > 0
val sizedOk = try { } catch (error: Exception) {
AudioTrack.getMinBufferSize(rate, mask, AudioFormat.ENCODING_IEC61937) > 0 false
} 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() * [supportsTrueHdMatCarrier] with the platform probes injected. Probes are only consulted on the
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE) * API tiers where they exist: [bitstreamSupported] (`getDirectPlaybackSupport`) on 33+ and
.setUsage(C.USAGE_MEDIA) * [directPlaybackSupported] (`AudioTrack.isDirectPlaybackSupported`) on 2932.
.build() */
.getPlatformAudioAttributes() internal fun trueHdMatCarrierSupported(
val probe = AudioFormat.Builder() sdkInt: Int,
.setEncoding(AudioFormat.ENCODING_IEC61937) canSizeCarrierBuffer: () -> Boolean,
.setChannelMask(mask) bitstreamSupported: () -> Boolean,
.setSampleRate(rate) directPlaybackSupported: () -> Boolean
.build() ): Boolean = when {
val support = AudioManager.getDirectPlaybackSupport(probe, audioAttributes) sdkInt < Build.VERSION_CODES.Q -> false
(support and AudioManager.DIRECT_PLAYBACK_BITSTREAM_SUPPORTED) != 0 !canSizeCarrierBuffer() -> false
} catch (error: Exception) { sdkInt >= Build.VERSION_CODES.TIRAMISU -> bitstreamSupported()
Log.w(TAG, "IEC 61937 carrier probe failed; not offering the TrueHD carrier", error) else -> directPlaybackSupported()
false
}
} }
@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( return TrueHdCarrierSink(
defaultSink = processedSink, defaultSink = processedSink,
carrierSink = buildCarrierSink(context, bufferSizeProvider), carrierSink = buildCarrierSink(context, bufferSizeProvider),
carrierRouteAvailable = { supportsTrueHdMatCarrier(context) }, carrierRouteAvailable = { supportsTrueHdMatCarrier() },
directOutputBlocked = { format -> shouldBlockDirectAudioOutput?.invoke(format) == true }, directOutputBlocked = { format -> shouldBlockDirectAudioOutput?.invoke(format) == true },
log = audioDiagnosticsLogger log = audioDiagnosticsLogger
).also { trueHdCarrierSink = it } ).also { trueHdCarrierSink = it }
@@ -82,4 +82,70 @@ class AudioOutputPolicyTest {
fun spdifListIsEmptyForPcmOnlyRoutes() { fun spdifListIsEmptyForPcmOnlyRoutes() {
assertEquals("", mpvSpdifCodecs { false }) 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+") }
)
)
}
}
} }