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 d188a809..2cf8ec62 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 @@ -104,6 +104,12 @@ internal fun supportedMpvSpdifCodecs(context: Context): String { * decode or wedge. */ 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 { @@ -112,7 +118,6 @@ internal fun supportsTrueHdMatCarrier(context: Context): Boolean { false } if (!sizedOk) return false - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return true return try { val audioAttributes = AudioAttributes.Builder() diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/TrueHdCarrierSink.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/TrueHdCarrierSink.kt index dd757e3f..34df6028 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/TrueHdCarrierSink.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/TrueHdCarrierSink.kt @@ -85,15 +85,25 @@ internal class TrueHdCarrierSink( * Speed changes are excluded deliberately: a bitstream cannot be resampled, so anything other * than 1.0x has to decode. The same is true of the existing downmix/normalization blocks, which * [directOutputBlocked] already reports. + * + * The rate family is decided from the format rather than from the packer, which only learns it + * from a major sync once buffers are already flowing — far too late for a selection that happens + * before configure. */ private fun shouldUseCarrier(format: Format): Boolean { if (format.sampleMimeType != MimeTypes.AUDIO_TRUEHD) return false - if (packer.unsupportedRateFamily) return false + if (!isCarrierRateFamily(format.sampleRate)) return false if (playbackParameters.speed != 1f) return false if (directOutputBlocked(format)) return false return carrierRouteAvailable() } + /** + * The 48kHz family rides the 192kHz carrier this path builds. The 44.1kHz family needs a 176.4kHz + * carrier, which would be a different AudioTrack tuple throughout, so it decodes instead. + */ + private fun isCarrierRateFamily(sampleRate: Int): Boolean = sampleRate == 48_000 || sampleRate == 96_000 || sampleRate == 192_000 + /** The PCM-shaped format the carrier delegate is configured with. */ private fun carrierFormat(): Format = Format.Builder() .setSampleMimeType(MimeTypes.AUDIO_RAW) @@ -102,11 +112,30 @@ internal class TrueHdCarrierSink( .setSampleRate(TrueHdMatPacker.CARRIER_SAMPLE_RATE) .build() - override fun supportsFormat(format: Format): Boolean = - if (shouldUseCarrier(format)) true else defaultSink.supportsFormat(format) + /** + * TrueHD is deliberately binary: the carrier, or decoded PCM. Never media3's own raw TrueHD path. + * + * That path builds an `ENCODING_DOLBY_TRUEHD` track at the *stream* rate, which is the + * configuration this issue is about — one box takes a single write and never advances its + * playback head, another freezes for ten seconds, and the third declines it and decodes anyway. + * Even Kodi's raw fallback is a different thing: it only offers raw TrueHD after verifying it at + * 192kHz, which media3 never requests. So when the carrier is unavailable — no IEC route, a speed + * change, downmix, normalization, or a 44.1kHz-family stream — report unsupported and let the + * bundled FFmpeg decoder take it, exactly as the existing decoded-PCM fallback does. + */ + private fun isTrueHd(format: Format): Boolean = format.sampleMimeType == MimeTypes.AUDIO_TRUEHD - override fun getFormatSupport(format: Format): Int = - if (shouldUseCarrier(format)) AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY else defaultSink.getFormatSupport(format) + override fun supportsFormat(format: Format): Boolean = when { + shouldUseCarrier(format) -> true + isTrueHd(format) -> false + else -> defaultSink.supportsFormat(format) + } + + override fun getFormatSupport(format: Format): Int = when { + shouldUseCarrier(format) -> AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY + isTrueHd(format) -> AudioSink.SINK_FORMAT_UNSUPPORTED + else -> defaultSink.getFormatSupport(format) + } override fun configure(inputFormat: Format, specifiedBufferSize: Int, outputChannels: IntArray?) { val useCarrier = shouldUseCarrier(inputFormat) @@ -145,17 +174,26 @@ internal class TrueHdCarrierSink( pendingBurst = null } if (!buffer.hasRemaining()) return true - - val sample = ByteArray(buffer.remaining()) - buffer.duplicate().get(sample) if (carrierAnchorUs == C.TIME_UNSET) carrierAnchorUs = presentationTimeUs + // One media3 sample holds many access units. The buffer position advances per unit, so a burst + // the delegate refuses leaves the units behind it in the buffer for the retry rather than + // dropping them — media3 re-delivers the same buffer whenever this returns false. + val remaining = ByteArray(buffer.remaining()) + buffer.duplicate().get(remaining) + var offset = 0 - while (offset < sample.size) { - val length = TrueHdMatPacker.accessUnitLength(sample, offset, sample.size) - if (length == 0) break - val burst = packer.packAccessUnit(sample, offset, length) + while (offset < remaining.size) { + val length = TrueHdMatPacker.accessUnitLength(remaining, offset, remaining.size) + if (length == 0) { + // Not a unit boundary we recognise. Consuming the tail keeps the stream moving; trying to + // resynchronise mid-carrier would splice a frame. + buffer.position(buffer.limit()) + return true + } + val burst = packer.packAccessUnit(remaining, offset, length) offset += length + buffer.position(buffer.position() + length) if (burst == null) continue val burstTimeUs = carrierAnchorUs + burstsSinceAnchor * CARRIER_BURST_DURATION_US @@ -163,15 +201,19 @@ internal class TrueHdCarrierSink( if (!carrierSink.handleBuffer(burst, burstTimeUs, 1)) { pendingBurst = burst pendingBurstTimeUs = burstTimeUs - break + return false } } - if (packer.unsupportedRateFamily) { - log?.invoke("info", "audio", "TrueHD is 44.1kHz-family; the MAT carrier does not cover it, decoding instead") - } - // The sample is consumed either way: a stashed burst is placed on the next call. - buffer.position(buffer.limit()) + if (packer.unsupportedRateFamily) { + // Selection is made from Format.sampleRate, so this means the bitstream disagrees with the + // container. Nothing is packed in that state, which would be silence rather than a glitch. + log?.invoke( + "warn", + "audio", + "TrueHD bitstream announced a 44.1kHz-family rate the container did not; carrier is producing nothing" + ) + } return true } diff --git a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/TrueHdCarrierSinkTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/TrueHdCarrierSinkTest.kt new file mode 100644 index 00000000..7a4309f3 --- /dev/null +++ b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/TrueHdCarrierSinkTest.kt @@ -0,0 +1,297 @@ +package com.edde746.plezy.exoplayer + +import android.media.AudioDeviceInfo +import androidx.annotation.OptIn +import androidx.media3.common.AudioAttributes +import androidx.media3.common.AuxEffectInfo +import androidx.media3.common.C +import androidx.media3.common.Format +import androidx.media3.common.MimeTypes +import androidx.media3.common.PlaybackParameters +import androidx.media3.common.util.Clock +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.analytics.PlayerId +import androidx.media3.exoplayer.audio.AudioSink +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Routing and back-pressure contract for the TrueHD MAT carrier (#1804). + * + * The carrier is bit-exact, so the two failure modes that matter here are losing bytes and letting + * the wrong sink handle TrueHD. + */ +@OptIn(UnstableApi::class) +class TrueHdCarrierSinkTest { + + private val accessUnits: ByteArray = + checkNotNull(javaClass.classLoader?.getResourceAsStream("truehd_access_units.bin")) + .use { it.readBytes() } + + private val golden: ByteArray = + checkNotNull(javaClass.classLoader?.getResourceAsStream("truehd_iec61937_golden.bin")) + .use { it.readBytes() } + + private fun trueHdFormat(sampleRate: Int = 48_000): Format = Format.Builder() + .setSampleMimeType(MimeTypes.AUDIO_TRUEHD) + .setChannelCount(6) + .setSampleRate(sampleRate) + .build() + + private fun sink( + carrier: FakeSink = FakeSink(), + normal: FakeSink = FakeSink(), + routeAvailable: Boolean = true, + blocked: Boolean = false + ) = TrueHdCarrierSink(normal, carrier, { routeAvailable }, { blocked }) + + // --- Selection --- + + /** + * TrueHD is the carrier or it is decoded. Falling through to the normal sink would hand media3 + * its raw ENCODING_DOLBY_TRUEHD path, which is the configuration that wedges on these devices. + */ + @Test + fun trueHdWithoutACarrierRouteIsReportedUnsupportedSoItDecodes() { + val normal = FakeSink().apply { formatSupport = AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY } + val carrierSink = sink(normal = normal, routeAvailable = false) + + assertFalse(carrierSink.supportsFormat(trueHdFormat())) + assertEquals(AudioSink.SINK_FORMAT_UNSUPPORTED, carrierSink.getFormatSupport(trueHdFormat())) + } + + @Test + fun trueHdWithACarrierRouteIsSupportedDirectly() { + val carrierSink = sink() + assertTrue(carrierSink.supportsFormat(trueHdFormat())) + assertEquals(AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY, carrierSink.getFormatSupport(trueHdFormat())) + } + + /** Downmix and normalization already force decoding; the carrier must not override that. */ + @Test + fun blockedDirectOutputDeclinesTheCarrier() { + val carrierSink = sink(blocked = true) + assertEquals(AudioSink.SINK_FORMAT_UNSUPPORTED, carrierSink.getFormatSupport(trueHdFormat())) + } + + /** + * 44.1kHz-family TrueHD rides a 176.4kHz carrier this path does not build. It has to be decided + * from the format: the packer only learns the rate from a major sync, long after selection, and + * selecting the carrier for it would produce silence. + */ + @Test + fun theFortyFourPointOneFamilyDeclinesTheCarrierFromTheFormatAlone() { + val carrierSink = sink() + assertEquals(AudioSink.SINK_FORMAT_UNSUPPORTED, carrierSink.getFormatSupport(trueHdFormat(sampleRate = 44_100))) + assertEquals(AudioSink.SINK_FORMAT_UNSUPPORTED, carrierSink.getFormatSupport(trueHdFormat(sampleRate = 176_400))) + } + + /** A bitstream cannot be resampled, so any speed other than 1.0x decodes. */ + @Test + fun aSpeedChangeDeclinesTheCarrier() { + val carrierSink = sink() + carrierSink.setPlaybackParameters(PlaybackParameters(1.5f)) + assertEquals(AudioSink.SINK_FORMAT_UNSUPPORTED, carrierSink.getFormatSupport(trueHdFormat())) + } + + /** Everything that is not TrueHD keeps going to the existing processed sink. */ + @Test + fun otherFormatsAreLeftToTheNormalSink() { + val normal = FakeSink().apply { formatSupport = AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY } + val carrierSink = sink(normal = normal) + val ac3 = Format.Builder().setSampleMimeType(MimeTypes.AUDIO_AC3).setSampleRate(48_000).build() + + assertEquals(AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY, carrierSink.getFormatSupport(ac3)) + assertTrue(carrierSink.supportsFormat(ac3)) + } + + // --- Back pressure --- + + /** + * The regression this exists for: a delegate that refuses a burst part-way through a sample must + * not cost the access units behind it. media3 re-delivers the same buffer after a false return, + * so the carrier has to resume from where it stopped and come out byte-identical. + */ + @Test + fun aRefusedBurstMidSampleLosesNoAccessUnits() { + val carrier = FakeSink() + val carrierSink = sink(carrier = carrier) + carrierSink.configure(trueHdFormat(), 0, null) + + // Refuse every third burst once, so rejections land inside samples rather than between them. + carrier.refuseEveryNth = 3 + + val produced = feedWholeStream(carrierSink) + assertArrayEquals("carrier output must survive back pressure unchanged", golden, produced) + assertTrue("the fake must actually have exercised the refusal path", carrier.refusals > 0) + } + + /** With no back pressure the same stream must produce exactly the same bytes. */ + @Test + fun theCarrierOutputMatchesTheGoldenStream() { + val carrier = FakeSink() + val carrierSink = sink(carrier = carrier) + carrierSink.configure(trueHdFormat(), 0, null) + + assertArrayEquals(golden, feedWholeStream(carrierSink)) + } + + /** A refused burst must be re-offered before any further input is taken. */ + @Test + fun aPendingBurstIsPlacedBeforeMoreInputIsConsumed() { + val carrier = FakeSink() + val carrierSink = sink(carrier = carrier) + carrierSink.configure(trueHdFormat(), 0, null) + carrier.refuseNext = true + + val buffer = ByteBuffer.wrap(accessUnits) + // Drive until the first refusal is observed. + while (buffer.hasRemaining() && carrierSink.handleBuffer(buffer, 0L, 1)) Unit + + assertTrue("expected a refusal to leave input unconsumed", buffer.hasRemaining()) + assertTrue(carrierSink.hasPendingData()) + } + + // --- Routing of controls --- + + @Test + fun persistentControlsReachBothDelegates() { + val carrier = FakeSink() + val normal = FakeSink() + val carrierSink = sink(carrier = carrier, normal = normal) + + carrierSink.setVolume(0.5f) + carrierSink.setAudioSessionId(7) + + assertEquals(0.5f, carrier.lastVolume, 0f) + assertEquals(0.5f, normal.lastVolume, 0f) + assertEquals(7, carrier.sessionId) + assertEquals(7, normal.sessionId) + } + + /** Silence skipping deletes "silent" bytes, which would break the carrier's frame cadence. */ + @Test + fun silenceSkippingNeverReachesTheCarrier() { + val carrier = FakeSink() + val normal = FakeSink() + val carrierSink = sink(carrier = carrier, normal = normal) + + carrierSink.setSkipSilenceEnabled(true) + + assertTrue(normal.skipSilence) + assertFalse("the carrier delegate must never skip silence", carrier.skipSilence) + } + + @Test + fun theCarrierDelegateIsConfiguredAsAFixedRatePcmCarrier() { + val carrier = FakeSink() + val carrierSink = sink(carrier = carrier) + + carrierSink.configure(trueHdFormat(), 0, null) + + val configured = checkNotNull(carrier.configuredFormat) + assertEquals(MimeTypes.AUDIO_RAW, configured.sampleMimeType) + assertEquals(C.ENCODING_PCM_16BIT, configured.pcmEncoding) + assertEquals(TrueHdMatPacker.CARRIER_SAMPLE_RATE, configured.sampleRate) + assertEquals(TrueHdMatPacker.CARRIER_CHANNEL_COUNT, configured.channelCount) + } + + @Test + fun nonCarrierAudioIsConfiguredOnTheNormalSink() { + val carrier = FakeSink() + val normal = FakeSink() + val carrierSink = sink(carrier = carrier, normal = normal) + val ac3 = Format.Builder().setSampleMimeType(MimeTypes.AUDIO_AC3).setSampleRate(48_000).build() + + carrierSink.configure(ac3, 0, null) + + assertSame(ac3, normal.configuredFormat) + assertEquals(null, carrier.configuredFormat) + } + + private fun feedWholeStream(carrierSink: TrueHdCarrierSink): ByteArray { + val buffer = ByteBuffer.wrap(accessUnits) + var guard = 0 + while (buffer.hasRemaining() && guard++ < 10_000) { + carrierSink.handleBuffer(buffer, 0L, 1) + } + val carrier = carrierSinkDelegate(carrierSink) + return carrier.written.toByteArray() + } + + private fun carrierSinkDelegate(sink: TrueHdCarrierSink): FakeSink { + val field = TrueHdCarrierSink::class.java.getDeclaredField("carrierSink") + field.isAccessible = true + return field.get(sink) as FakeSink + } + + /** Minimal AudioSink that records what it was handed and can apply back pressure. */ + private class FakeSink : AudioSink { + val written = ByteArrayOutputStream() + var configuredFormat: Format? = null + var formatSupport: Int = AudioSink.SINK_FORMAT_UNSUPPORTED + var lastVolume: Float = -1f + var sessionId: Int = -1 + var skipSilence: Boolean = false + var refuseNext: Boolean = false + var refuseEveryNth: Int = 0 + var refusals: Int = 0 + private var offered = 0 + + override fun handleBuffer(buffer: ByteBuffer, presentationTimeUs: Long, encodedAccessUnitCount: Int): Boolean { + offered++ + val refuse = refuseNext || (refuseEveryNth > 0 && offered % refuseEveryNth == 0) + if (refuse) { + refuseNext = false + refusals++ + // Refuse once per burst: the retry must be accepted or the stream cannot progress. + offered++ + return false + } + val copy = ByteArray(buffer.remaining()) + buffer.duplicate().get(copy) + written.write(copy) + buffer.position(buffer.limit()) + return true + } + + override fun configure(inputFormat: Format, specifiedBufferSize: Int, outputChannels: IntArray?) { + configuredFormat = inputFormat + } + + override fun supportsFormat(format: Format) = formatSupport == AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY + override fun getFormatSupport(format: Format) = formatSupport + override fun setVolume(volume: Float) { lastVolume = volume } + override fun setAudioSessionId(audioSessionId: Int) { sessionId = audioSessionId } + override fun setSkipSilenceEnabled(skipSilenceEnabled: Boolean) { skipSilence = skipSilenceEnabled } + override fun getSkipSilenceEnabled() = skipSilence + + override fun setListener(listener: AudioSink.Listener) = Unit + override fun setPlayerId(playerId: PlayerId?) = Unit + override fun setClock(clock: Clock) = Unit + override fun getCurrentPositionUs(sourceEnded: Boolean) = 0L + override fun play() = Unit + override fun handleDiscontinuity() = Unit + override fun playToEndOfStream() = Unit + override fun isEnded() = false + override fun hasPendingData() = false + override fun setPlaybackParameters(playbackParameters: PlaybackParameters) = Unit + override fun getPlaybackParameters(): PlaybackParameters = PlaybackParameters.DEFAULT + override fun setAudioAttributes(audioAttributes: AudioAttributes) = Unit + override fun getAudioAttributes(): AudioAttributes? = null + override fun setAuxEffectInfo(auxEffectInfo: AuxEffectInfo) = Unit + override fun setPreferredDevice(audioDeviceInfo: AudioDeviceInfo?) = Unit + override fun getAudioTrackBufferSizeUs() = 0L + override fun enableTunnelingV21() = Unit + override fun disableTunneling() = Unit + override fun pause() = Unit + override fun flush() = Unit + override fun reset() = Unit + } +}