fix(player): make TrueHD carrier-or-decode and never lose access units

Three defects in the carrier path, two of them found on hardware (#1804).

Falling through to the normal sink when the carrier was unavailable handed
TrueHD straight back to media3's raw ENCODING_DOLBY_TRUEHD path — the exact
configuration this issue is about. TrueHD is now binary: the carrier, or
reported unsupported so the bundled FFmpeg decoder takes it. media3's raw path
has no demonstrated working case here and two broken ones, and even Kodi's raw
fallback is a different thing, offered only after verifying at 192kHz.

The 44.1kHz family was decided from a packer flag that is only set once a major
sync has been parsed, long after selection. The carrier was therefore chosen for
those streams and then packed nothing, which is silence rather than a glitch. It
is decided from Format.sampleRate now, with the packer flag left as a loud
runtime backstop for a bitstream that disagrees with its container.

handleBuffer consumed the whole input buffer even when a burst was refused
part-way through, dropping every access unit behind it — a media3 sample holds
sixteen. The buffer position now advances per unit and the method returns false
with the remainder intact, which is media3's own retry contract. A test rejects
a burst mid-sample and asserts the carrier output is still byte-identical.

The capability gate also needed tightening. getMinBufferSize answers yes for the
192kHz/7.1 IEC tuple on a Shield 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 separate the two, so the
carrier is not offered below API 33 and TrueHD decodes exactly as before.

Verified on both connected boxes. SEI Box R (Android 14): carrier selected,
AudioTrack built as IEC61937 at 192kHz/7.1, no decoder instantiated, clock
tracks wall time. Nvidia Shield (Android 11): carrier declined, FFmpeg decoder
selected, identical to its behaviour before this work.
This commit is contained in:
edde746
2026-08-08 10:51:15 +02:00
parent b7a438789f
commit 3b76cf3948
3 changed files with 363 additions and 19 deletions
@@ -104,6 +104,12 @@ internal fun supportedMpvSpdifCodecs(context: Context): String {
* decode or wedge. * decode or wedge.
*/ */
internal fun supportsTrueHdMatCarrier(context: Context): Boolean { 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 rate = TrueHdMatPacker.CARRIER_SAMPLE_RATE
val mask = AudioFormat.CHANNEL_OUT_7POINT1_SURROUND val mask = AudioFormat.CHANNEL_OUT_7POINT1_SURROUND
val sizedOk = try { val sizedOk = try {
@@ -112,7 +118,6 @@ internal fun supportsTrueHdMatCarrier(context: Context): Boolean {
false false
} }
if (!sizedOk) return false if (!sizedOk) return false
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return true
return try { return try {
val audioAttributes = AudioAttributes.Builder() val audioAttributes = AudioAttributes.Builder()
@@ -85,15 +85,25 @@ internal class TrueHdCarrierSink(
* Speed changes are excluded deliberately: a bitstream cannot be resampled, so anything other * 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 * than 1.0x has to decode. The same is true of the existing downmix/normalization blocks, which
* [directOutputBlocked] already reports. * [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 { private fun shouldUseCarrier(format: Format): Boolean {
if (format.sampleMimeType != MimeTypes.AUDIO_TRUEHD) return false 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 (playbackParameters.speed != 1f) return false
if (directOutputBlocked(format)) return false if (directOutputBlocked(format)) return false
return carrierRouteAvailable() 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. */ /** The PCM-shaped format the carrier delegate is configured with. */
private fun carrierFormat(): Format = Format.Builder() private fun carrierFormat(): Format = Format.Builder()
.setSampleMimeType(MimeTypes.AUDIO_RAW) .setSampleMimeType(MimeTypes.AUDIO_RAW)
@@ -102,11 +112,30 @@ internal class TrueHdCarrierSink(
.setSampleRate(TrueHdMatPacker.CARRIER_SAMPLE_RATE) .setSampleRate(TrueHdMatPacker.CARRIER_SAMPLE_RATE)
.build() .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 = override fun supportsFormat(format: Format): Boolean = when {
if (shouldUseCarrier(format)) AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY else defaultSink.getFormatSupport(format) 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?) { override fun configure(inputFormat: Format, specifiedBufferSize: Int, outputChannels: IntArray?) {
val useCarrier = shouldUseCarrier(inputFormat) val useCarrier = shouldUseCarrier(inputFormat)
@@ -145,17 +174,26 @@ internal class TrueHdCarrierSink(
pendingBurst = null pendingBurst = null
} }
if (!buffer.hasRemaining()) return true if (!buffer.hasRemaining()) return true
val sample = ByteArray(buffer.remaining())
buffer.duplicate().get(sample)
if (carrierAnchorUs == C.TIME_UNSET) carrierAnchorUs = presentationTimeUs 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 var offset = 0
while (offset < sample.size) { while (offset < remaining.size) {
val length = TrueHdMatPacker.accessUnitLength(sample, offset, sample.size) val length = TrueHdMatPacker.accessUnitLength(remaining, offset, remaining.size)
if (length == 0) break if (length == 0) {
val burst = packer.packAccessUnit(sample, offset, length) // 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 offset += length
buffer.position(buffer.position() + length)
if (burst == null) continue if (burst == null) continue
val burstTimeUs = carrierAnchorUs + burstsSinceAnchor * CARRIER_BURST_DURATION_US val burstTimeUs = carrierAnchorUs + burstsSinceAnchor * CARRIER_BURST_DURATION_US
@@ -163,15 +201,19 @@ internal class TrueHdCarrierSink(
if (!carrierSink.handleBuffer(burst, burstTimeUs, 1)) { if (!carrierSink.handleBuffer(burst, burstTimeUs, 1)) {
pendingBurst = burst pendingBurst = burst
pendingBurstTimeUs = burstTimeUs 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. if (packer.unsupportedRateFamily) {
buffer.position(buffer.limit()) // 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 return true
} }
@@ -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
}
}