diff --git a/android/app/src/androidTest/kotlin/com/edde746/plezy/exoplayer/TrueHdSpeedTransitionTest.kt b/android/app/src/androidTest/kotlin/com/edde746/plezy/exoplayer/TrueHdSpeedTransitionTest.kt index 0153fd9e..391e42e6 100644 --- a/android/app/src/androidTest/kotlin/com/edde746/plezy/exoplayer/TrueHdSpeedTransitionTest.kt +++ b/android/app/src/androidTest/kotlin/com/edde746/plezy/exoplayer/TrueHdSpeedTransitionTest.kt @@ -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 } diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/AudioOutputPolicy.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/AudioOutputPolicy.kt index 2cf8ec62..42268d2a 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/AudioOutputPolicy.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/AudioOutputPolicy.kt @@ -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 29–32: `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 29–32. + */ +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() diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/PlezyRenderersFactory.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/PlezyRenderersFactory.kt index c5cc95fd..af371f8c 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/PlezyRenderersFactory.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/PlezyRenderersFactory.kt @@ -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 } diff --git a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/AudioOutputPolicyTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/AudioOutputPolicyTest.kt index 59a9f396..c3595af5 100644 --- a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/AudioOutputPolicyTest.kt +++ b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/AudioOutputPolicyTest.kt @@ -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+") } + ) + ) + } + } }