feat(player): bitstream TrueHD through the MAT/IEC 61937 carrier

Copies the path Kodi uses, and replaces nothing-but-detection with a route that
actually plays (#1804).

Android will not bitstream raw TrueHD on the TV routes measured here. Both
connected boxes report ENCODING_DOLBY_TRUEHD as offload-only while reporting
ENCODING_IEC61937 at 192kHz/7.1 as bitstream-capable. Kodi models exactly that
split: it packs the carrier itself and offers "AudioTrack (IEC)" as the
recommended sink, treating raw TrueHD as a fallback that it still runs at
192kHz. Media3 only ever hands Android raw TrueHD at the stream rate, which on
the reporter's box takes one write and then never advances the playback head.

TrueHdCarrierSink routes TrueHD onto a dedicated delegate and leaves everything
else on the existing processed sink. The split is deliberate rather than
enforcing that the normal processors stay inactive: the carrier is a bit-exact
byte stream shaped like PCM, so a downmix, Sonic pass or silence skip turns it
into full-scale noise at the receiver. A delegate built with an empty
AudioProcessorChain makes that impossible by construction, instead of putting
the guarantee in a different class from the thing it protects.

The carrier delegate keeps OutputConfig at PCM 16-bit so media3's position,
pending-data and release accounting all stay on their mature PCM path — correct
here, because after packing the stream really is a fixed-rate 192kHz 8-channel
carrier. Only the AudioTrack itself is switched, through the builder modifier
upstream applies just before AudioTrack.Builder.build(). That avoids
reimplementing AudioOutput and avoids the encoded frame-domain mismatch in
androidx/media#3329.

Burst timestamps come from the carrier cadence rather than from whichever
access unit closed the frame; anchoring on the closing unit drifts against the
time the sink derives from written frames and reports a discontinuity on nearly
every frame.

Availability is Kodi's test, not media3's: getMinBufferSize for the exact
192kHz/7.1 IEC tuple, plus getDirectPlaybackSupport where it exists to confirm
the route will bitstream rather than quietly decode. Speed changes, downmix,
normalization and 44.1kHz-family streams all decline the carrier and decode.

Verified on a SEI Robotics Box R 4K Plus (Android 14, armeabi-v7a): the carrier
is selected, the AudioTrack is built as IEC61937 at 192kHz/7.1, no audio decoder
is instantiated, zero timestamp discontinuities, and the clock tracks wall time
with no frozen samples. The same box freezes for ten seconds on raw TrueHD.
This commit is contained in:
edde746
2026-08-08 10:51:15 +02:00
parent 33c33c3d57
commit b7a438789f
4 changed files with 435 additions and 7 deletions
@@ -2,6 +2,9 @@ package com.edde746.plezy.exoplayer
import android.content.Context
import android.media.AudioFormat
import android.media.AudioManager
import android.media.AudioTrack
import android.os.Build
import android.util.Log
import androidx.annotation.OptIn
import androidx.media3.common.AudioAttributes
@@ -86,3 +89,46 @@ internal fun supportedMpvSpdifCodecs(context: Context): String {
}
return mpvSpdifCodecs(capabilities::supportsEncoding)
}
/**
* Whether this route can carry TrueHD as MAT inside IEC 61937 (#1804).
*
* This is Kodi's test, and deliberately not media3's. Kodi asks the AudioTrack layer whether it can
* size a buffer for one exact tuple — `getMinBufferSize(rate, mask, encoding) > 0` — and gates the
* carrier on the 192kHz/7.1 IEC combination specifically. media3 instead asks the audio policy
* 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.
*/
internal fun supportsTrueHdMatCarrier(context: Context): Boolean {
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
}
if (!sizedOk) return false
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return true
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
}
}
@@ -8,6 +8,7 @@ import androidx.annotation.OptIn
import androidx.media3.common.Format
import androidx.media3.common.MimeTypes
import androidx.media3.common.PlaybackParameters
import androidx.media3.common.audio.AudioProcessor
import androidx.media3.common.audio.ChannelMixingAudioProcessor
import androidx.media3.common.audio.ChannelMixingMatrix
import androidx.media3.common.util.Clock
@@ -185,7 +186,7 @@ class PlezyRenderersFactory(context: Context) : DefaultRenderersFactory(context)
.setAudioOutputProvider(RawPositionOutputProvider(realProvider, rawPositionUs, audioDiagnosticsLogger))
.build()
return PositionFixAudioSink(
val processedSink = PositionFixAudioSink(
defaultSink,
rawPositionUs,
audioDelayUs,
@@ -193,7 +194,89 @@ class PlezyRenderersFactory(context: Context) : DefaultRenderersFactory(context)
onAudioCapabilitiesChanged,
audioDiagnosticsLogger
)
return TrueHdCarrierSink(
defaultSink = processedSink,
carrierSink = buildCarrierSink(context, bufferSizeProvider),
carrierRouteAvailable = { supportsTrueHdMatCarrier(context) },
directOutputBlocked = { format -> shouldBlockDirectAudioOutput?.invoke(format) == true },
log = audioDiagnosticsLogger
)
}
/**
* The delegate that carries packed TrueHD (#1804).
*
* Deliberately separate from the processed sink, and deliberately barren: an empty
* [DefaultAudioSink.AudioProcessorChain] means no downmix, no Sonic, no silence skipping and no
* float conversion can ever touch the bytes. The carrier only looks like PCM; a single mutated
* sample reaches the receiver as full-scale noise rather than as a glitch.
*
* The output provider keeps `OutputConfig.encoding` at PCM 16-bit so every position, pending-data
* and release accounting in media3 stays in its mature PCM path — which is correct here, because
* after packing the stream genuinely is a fixed-rate 192kHz 8-channel carrier. Only the
* `AudioTrack` itself is switched to `ENCODING_IEC61937`, via the builder modifier that upstream
* applies immediately before `AudioTrack.Builder.build()`.
*/
private fun buildCarrierSink(
context: Context,
bufferSizeProvider: DefaultAudioTrackBufferSizeProvider
): AudioSink {
val provider = AudioTrackAudioOutputProvider.Builder(context)
.setAudioTrackBufferSizeProvider(bufferSizeProvider)
.apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
setAudioTrackBuilderModifier { builder, config ->
builder.setAudioFormat(
android.media.AudioFormat.Builder()
.setEncoding(android.media.AudioFormat.ENCODING_IEC61937)
.setChannelMask(android.media.AudioFormat.CHANNEL_OUT_7POINT1_SURROUND)
.setSampleRate(config.sampleRate)
.build()
)
// The AudioTrackConfig media3 reports upstream is built from OutputConfig, which stays
// PCM so the accounting stays in the PCM domain — so it cannot show the real encoding.
// This is the only place that can confirm what the AudioTrack was actually built as.
audioDiagnosticsLogger?.invoke(
"info",
"audio",
"Carrier AudioTrack: encoding=IEC61937 rate=${config.sampleRate} " +
"mask=0x${android.media.AudioFormat.CHANNEL_OUT_7POINT1_SURROUND.toString(16)} " +
"buffer=${config.bufferSize}"
)
}
}
}
.build()
return DefaultAudioSink.Builder(context)
.setEnableFloatOutput(false)
// Not DefaultAudioProcessorChain: that one appends silence-skipping and Sonic whatever it is
// constructed with, and either would rewrite carrier bytes. This chain has nothing in it, so
// AudioProcessingPipeline is a straight passthrough. Speed is reported as applied without
// resampling because the carrier delegate routes playback parameters to the AudioTrack, and
// the sink refuses the carrier outright at any speed other than 1.0x.
.setAudioProcessorChain(EmptyAudioProcessorChain())
.setAudioOutputProvider(provider)
.build()
}
}
/**
* An [AudioProcessorChain] that owns no processors, for the MAT carrier delegate (#1804).
*
* `DefaultAudioProcessorChain` always contributes silence-skipping and Sonic; both rewrite samples,
* which is fatal to a bit-exact IEC 61937 carrier. Speed is reported back unchanged because the
* carrier delegate applies playback parameters at the AudioTrack, and the routing sink declines the
* carrier entirely at any speed other than 1.0x.
*/
@OptIn(UnstableApi::class)
private class EmptyAudioProcessorChain : DefaultAudioSink.AudioProcessorChain {
override fun getAudioProcessors(): Array<AudioProcessor> = emptyArray()
override fun applyPlaybackParameters(playbackParameters: PlaybackParameters): PlaybackParameters = playbackParameters
override fun applySkipSilenceEnabled(skipSilenceEnabled: Boolean): Boolean = false
override fun getMediaDuration(playoutDuration: Long): Long = playoutDuration
override fun getSkippedOutputFrameCount(): Long = 0L
}
/**
@@ -0,0 +1,298 @@
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.nio.ByteBuffer
/**
* Routes Dolby TrueHD through a MAT/IEC 61937 carrier, and everything else through the normal sink
* (#1804).
*
* Android will not bitstream raw TrueHD on the TV routes measured for this issue: the platform
* reports `ENCODING_DOLBY_TRUEHD` as offload-only while reporting `ENCODING_IEC61937` at 192kHz/7.1
* as bitstream-capable. Kodi models the same split and packs the carrier itself; media3 only ever
* hands Android raw TrueHD, at the stream rate. This sink adds the missing path.
*
* **Why two delegates rather than one sink with the processors held inactive.** The carrier is a
* bit-exact byte stream that happens to be shaped like PCM. Any sample mutation — downmix, Sonic,
* silence skipping, float conversion — turns it into full-scale noise at the receiver. Keeping the
* carrier on a delegate built with an empty [AudioProcessorChain] makes that impossible by
* construction. The alternative, asserting the normal chain stays inactive, puts the guarantee in a
* different class from the thing it protects, so adding a processor later would silently break
* playback in the loudest possible way.
*
* [carrierSink] must therefore be built with no audio processors and with an output provider whose
* `AudioTrack` is forced to `ENCODING_IEC61937`; see [PlezyRenderersFactory].
*
* Selection happens only in [configure]. Persistent controls and the listener are mirrored to both
* delegates so either can be activated later; per-stream calls go to the active one alone.
*/
@OptIn(UnstableApi::class)
internal class TrueHdCarrierSink(
private val defaultSink: AudioSink,
private val carrierSink: AudioSink,
/** Whether the current route can bitstream the carrier tuple. Evaluated per format. */
private val carrierRouteAvailable: () -> Boolean,
/** Whether policy currently forbids bitstreaming at all (downmix, normalization, user setting). */
private val directOutputBlocked: (Format) -> Boolean,
private val log: ((String, String, String) -> Unit)? = null
) : AudioSink {
private companion object {
/** One MAT frame is exactly one carrier period: 3840 frames at 192kHz. */
const val CARRIER_BURST_DURATION_US =
TrueHdMatPacker.MAT_PKT_OFFSET.toLong() / TrueHdMatPacker.CARRIER_BYTES_PER_FRAME *
1_000_000L / TrueHdMatPacker.CARRIER_SAMPLE_RATE
}
private val packer = TrueHdMatPacker()
private var active: AudioSink = defaultSink
private var carrierActive = false
/** A burst the delegate refused; it must be placed before any further input is consumed. */
private var pendingBurst: ByteBuffer? = null
private var pendingBurstTimeUs: Long = 0
/**
* Anchor for carrier timestamps, and how many bursts have been emitted since it.
*
* Each MAT frame is exactly one carrier period of audio, so timestamps are derived from the
* cadence rather than from whichever access unit happened to close the frame. Handing the sink
* the closing unit's own presentation time drifts against the time it derives from written
* frames, which it reports as a discontinuity on nearly every frame.
*/
private var carrierAnchorUs: Long = C.TIME_UNSET
private var burstsSinceAnchor: Long = 0
private var playbackParameters: PlaybackParameters = PlaybackParameters.DEFAULT
// --- Selection ---
/**
* True when this format should ride the carrier.
*
* 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.
*/
private fun shouldUseCarrier(format: Format): Boolean {
if (format.sampleMimeType != MimeTypes.AUDIO_TRUEHD) return false
if (packer.unsupportedRateFamily) return false
if (playbackParameters.speed != 1f) return false
if (directOutputBlocked(format)) return false
return carrierRouteAvailable()
}
/** The PCM-shaped format the carrier delegate is configured with. */
private fun carrierFormat(): Format = Format.Builder()
.setSampleMimeType(MimeTypes.AUDIO_RAW)
.setPcmEncoding(C.ENCODING_PCM_16BIT)
.setChannelCount(TrueHdMatPacker.CARRIER_CHANNEL_COUNT)
.setSampleRate(TrueHdMatPacker.CARRIER_SAMPLE_RATE)
.build()
override fun supportsFormat(format: Format): Boolean =
if (shouldUseCarrier(format)) true else defaultSink.supportsFormat(format)
override fun getFormatSupport(format: Format): Int =
if (shouldUseCarrier(format)) AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY else defaultSink.getFormatSupport(format)
override fun configure(inputFormat: Format, specifiedBufferSize: Int, outputChannels: IntArray?) {
val useCarrier = shouldUseCarrier(inputFormat)
if (useCarrier != carrierActive) {
log?.invoke(
"info",
"audio",
if (useCarrier) {
"TrueHD via MAT/IEC 61937 carrier at ${TrueHdMatPacker.CARRIER_SAMPLE_RATE}Hz/" +
"${TrueHdMatPacker.CARRIER_CHANNEL_COUNT}ch"
} else {
"Leaving the MAT carrier; audio returns to the normal sink"
}
)
}
carrierActive = useCarrier
active = if (useCarrier) carrierSink else defaultSink
discardCarrierState()
if (useCarrier) {
carrierSink.configure(carrierFormat(), specifiedBufferSize, null)
} else {
defaultSink.configure(inputFormat, specifiedBufferSize, outputChannels)
}
}
// --- Stream path: active delegate only ---
override fun handleBuffer(buffer: ByteBuffer, presentationTimeUs: Long, encodedAccessUnitCount: Int): Boolean {
if (!carrierActive) return defaultSink.handleBuffer(buffer, presentationTimeUs, encodedAccessUnitCount)
// A refused burst blocks everything: placing it must come before consuming more input, or the
// carrier would lose a frame.
pendingBurst?.let { burst ->
if (!carrierSink.handleBuffer(burst, pendingBurstTimeUs, 1)) return false
pendingBurst = null
}
if (!buffer.hasRemaining()) return true
val sample = ByteArray(buffer.remaining())
buffer.duplicate().get(sample)
if (carrierAnchorUs == C.TIME_UNSET) carrierAnchorUs = presentationTimeUs
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)
offset += length
if (burst == null) continue
val burstTimeUs = carrierAnchorUs + burstsSinceAnchor * CARRIER_BURST_DURATION_US
burstsSinceAnchor++
if (!carrierSink.handleBuffer(burst, burstTimeUs, 1)) {
pendingBurst = burst
pendingBurstTimeUs = burstTimeUs
break
}
}
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())
return true
}
override fun getCurrentPositionUs(sourceEnded: Boolean): Long = active.getCurrentPositionUs(sourceEnded)
override fun playToEndOfStream() {
// A partially filled MAT frame cannot be emitted; up to 20ms is dropped at the end of a stream.
active.playToEndOfStream()
}
override fun isEnded(): Boolean = active.isEnded()
override fun hasPendingData(): Boolean = pendingBurst != null || active.hasPendingData()
override fun handleDiscontinuity() {
discardCarrierState()
active.handleDiscontinuity()
}
override fun play() = active.play()
override fun pause() = active.pause()
override fun flush() {
discardCarrierState()
active.flush()
}
override fun getAudioTrackBufferSizeUs(): Long = active.getAudioTrackBufferSizeUs()
private fun discardCarrierState() {
packer.reset()
pendingBurst = null
// Re-anchor on the next burst: after a seek the carrier restarts from a new media time.
carrierAnchorUs = C.TIME_UNSET
burstsSinceAnchor = 0
}
// --- Persistent state: mirrored, so either delegate can be activated later ---
override fun setListener(listener: AudioSink.Listener) {
defaultSink.setListener(listener)
carrierSink.setListener(listener)
}
override fun setPlayerId(playerId: PlayerId?) {
defaultSink.setPlayerId(playerId)
carrierSink.setPlayerId(playerId)
}
override fun setClock(clock: Clock) {
defaultSink.setClock(clock)
carrierSink.setClock(clock)
}
override fun setPlaybackParameters(playbackParameters: PlaybackParameters) {
this.playbackParameters = playbackParameters
defaultSink.setPlaybackParameters(playbackParameters)
carrierSink.setPlaybackParameters(playbackParameters)
}
override fun getPlaybackParameters(): PlaybackParameters = active.getPlaybackParameters()
override fun setSkipSilenceEnabled(skipSilenceEnabled: Boolean) {
// Only reaches the normal sink: the carrier delegate has no processors to skip silence with,
// and dropping "silent" carrier bytes would break the frame cadence.
defaultSink.setSkipSilenceEnabled(skipSilenceEnabled)
}
override fun getSkipSilenceEnabled(): Boolean = defaultSink.getSkipSilenceEnabled()
override fun setAudioAttributes(audioAttributes: AudioAttributes) {
defaultSink.setAudioAttributes(audioAttributes)
carrierSink.setAudioAttributes(audioAttributes)
}
override fun getAudioAttributes(): AudioAttributes? = active.audioAttributes
override fun setAudioSessionId(audioSessionId: Int) {
defaultSink.setAudioSessionId(audioSessionId)
carrierSink.setAudioSessionId(audioSessionId)
}
override fun setAuxEffectInfo(auxEffectInfo: AuxEffectInfo) {
defaultSink.setAuxEffectInfo(auxEffectInfo)
carrierSink.setAuxEffectInfo(auxEffectInfo)
}
override fun setPreferredDevice(audioDeviceInfo: AudioDeviceInfo?) {
defaultSink.setPreferredDevice(audioDeviceInfo)
carrierSink.setPreferredDevice(audioDeviceInfo)
}
override fun setOutputStreamOffsetUs(outputStreamOffsetUs: Long) {
defaultSink.setOutputStreamOffsetUs(outputStreamOffsetUs)
carrierSink.setOutputStreamOffsetUs(outputStreamOffsetUs)
}
override fun setVolume(volume: Float) {
defaultSink.setVolume(volume)
carrierSink.setVolume(volume)
}
override fun enableTunnelingV21() {
// Tunneling is a decoded-PCM/video-sync arrangement; the carrier never uses it.
defaultSink.enableTunnelingV21()
}
override fun disableTunneling() {
defaultSink.disableTunneling()
}
override fun reset() {
discardCarrierState()
defaultSink.reset()
carrierSink.reset()
}
override fun release() {
discardCarrierState()
defaultSink.release()
carrierSink.release()
}
}
@@ -20,9 +20,8 @@ import org.junit.Test
*/
class TrueHdMatPackerTest {
private fun resource(name: String): ByteArray =
checkNotNull(javaClass.classLoader?.getResourceAsStream(name)) { "missing fixture $name" }
.use { it.readBytes() }
private fun resource(name: String): ByteArray = checkNotNull(javaClass.classLoader?.getResourceAsStream(name)) { "missing fixture $name" }
.use { it.readBytes() }
private val accessUnits by lazy { resource("truehd_access_units.bin") }
private val golden by lazy { resource("truehd_iec61937_golden.bin") }
@@ -116,7 +115,10 @@ class TrueHdMatPackerTest {
while (offset < accessUnits.size) {
val length = TrueHdMatPacker.accessUnitLength(accessUnits, offset, accessUnits.size)
if (length == 0) break
packer.packAccessUnit(accessUnits, offset, length)?.let { seen[it] = true; bursts++ }
packer.packAccessUnit(accessUnits, offset, length)?.let {
seen[it] = true
bursts++
}
offset += length
}
assertTrue("expected several bursts, saw $bursts", bursts > 2)
@@ -150,6 +152,5 @@ class TrueHdMatPackerTest {
}
}
private fun readLittleEndianShort(data: ByteArray, offset: Int): Int =
(data[offset].toInt() and 0xFF) or ((data[offset + 1].toInt() and 0xFF) shl 8)
private fun readLittleEndianShort(data: ByteArray, offset: Int): Int = (data[offset].toInt() and 0xFF) or ((data[offset + 1].toInt() and 0xFF) shl 8)
}