feat(player): bitstream DTS-HD MA through the IEC 61937 carrier on Android

Fire TV Stick 4K Max (and other Fire OS devices) advertise ENCODING_DTS_HD on
the HDMI route but only implement the DTS-HD basic profile, so media3's raw
path builds an AudioTrack that drains normally while the receiver hears
silence. The same routes do bitstream the 192kHz/7.1 ENCODING_IEC61937 carrier
TrueHD already rides, so DTS-HD MA access units are now packed into DTS type IV
bursts (a port of FFmpeg's spdif_header_dts4 at the 768kHz HD rate, the same
bytes Kodi's IEC packer produces) and played through that carrier.

The TrueHD MAT carrier sink is generalized into IecCarrierSink with one
IecCarrierPacker per codec. While the carrier route exists DTS-HD is binary -
the carrier or decoded PCM, never the lying raw path; routes without the
carrier keep the pre-carrier raw behavior. Master Audio peaks the carrier
cannot hold strip to the always-fitting core substream for ~60s, exactly as
FFmpeg does, and the packer output is pinned byte-for-byte against FFmpeg
spdif golden fixtures.

close #1988
This commit is contained in:
edde746
2026-08-17 20:58:04 +02:00
parent efd33b3a4e
commit 658469f954
14 changed files with 1033 additions and 125 deletions
@@ -107,7 +107,7 @@ class TrueHdSpeedTransitionTest {
// AudioTrackConfig is built from OutputConfig, which stays PCM16 by design; the real
// AudioTrack format is swapped in the builder modifier. The observable carrier signature is
// therefore the 192kHz carrier rate with no decoder instantiated.
if (carrierEncoding != TrueHdMatPacker.CARRIER_SAMPLE_RATE || decoderBefore != null) {
if (carrierEncoding != IecCarrier.SAMPLE_RATE || decoderBefore != null) {
Log.i(TAG, "==== SKIPPED: device does not take the carrier (rate=$carrierEncoding) ====")
teardown(handler, player, thread, fixture)
return
@@ -137,12 +137,12 @@ class TrueHdSpeedTransitionTest {
assertEquals(
"returning to 1x must put TrueHD back on the carrier",
TrueHdMatPacker.CARRIER_SAMPLE_RATE,
IecCarrier.SAMPLE_RATE,
rateRestored
)
assertNotEquals(
"TrueHD must leave the IEC 61937 carrier when speed leaves 1x",
TrueHdMatPacker.CARRIER_SAMPLE_RATE,
IecCarrier.SAMPLE_RATE,
encodingAfter
)
assertTrue("a decoder must take over the TrueHD track", decoderAfter != null)
@@ -203,7 +203,7 @@ class TrueHdSpeedTransitionTest {
@Test
fun aRateFamilyMismatchFallsBackToTheDecoderInsteadOfGoingSilent() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
if (!supportsTrueHdMatCarrier()) {
if (!supportsIecCarrier()) {
Log.i(TAG, "==== MISMATCH SKIPPED: device has no carrier route ====")
return
}
@@ -303,7 +303,7 @@ class TrueHdSpeedTransitionTest {
assertTrue("a decoder must take the stream over instead of the carrier", decoder != null)
assertNotEquals(
"the stream must not still be riding the carrier",
TrueHdMatPacker.CARRIER_SAMPLE_RATE,
IecCarrier.SAMPLE_RATE,
rate
)
assertTrue("playback must keep advancing after the fallback", positionSecond > positionFirst)
@@ -92,7 +92,8 @@ internal fun supportedMpvSpdifCodecs(context: Context): String {
}
/**
* Whether this route can carry TrueHD as MAT inside IEC 61937 (#1804).
* Whether this route can carry a packed bitstream inside IEC 61937 at 192kHz/7.1 — TrueHD as MAT
* (#1804) and DTS-HD MA as DTS type IV (#1988) both ride this exact tuple.
*
* 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
@@ -112,15 +113,15 @@ internal fun supportedMpvSpdifCodecs(context: Context): String {
* 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.
* - Below API 29 there is no oracle at all, so the carrier is not offered and the stream decodes
* as before.
*/
internal fun supportsTrueHdMatCarrier(): Boolean = trueHdMatCarrierSupported(
internal fun supportsIecCarrier(): Boolean = iecCarrierSupported(
sdkInt = Build.VERSION.SDK_INT,
canSizeCarrierBuffer = {
try {
AudioTrack.getMinBufferSize(
TrueHdMatPacker.CARRIER_SAMPLE_RATE,
IecCarrier.SAMPLE_RATE,
AudioFormat.CHANNEL_OUT_7POINT1_SURROUND,
AudioFormat.ENCODING_IEC61937
) > 0
@@ -128,7 +129,7 @@ internal fun supportsTrueHdMatCarrier(): Boolean = trueHdMatCarrierSupported(
false
}
},
// The SDK_INT guards repeat trueHdMatCarrierSupported's tiering only because lint's NewApi
// The SDK_INT guards repeat iecCarrierSupported's tiering only because lint's NewApi
// check cannot see through the injected lambdas.
bitstreamSupported = {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && iecCarrierBitstreamSupported()
@@ -139,11 +140,11 @@ internal fun supportsTrueHdMatCarrier(): Boolean = trueHdMatCarrierSupported(
)
/**
* [supportsTrueHdMatCarrier] with the platform probes injected. Probes are only consulted on the
* [supportsIecCarrier] 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 2932.
*/
internal fun trueHdMatCarrierSupported(
internal fun iecCarrierSupported(
sdkInt: Int,
canSizeCarrierBuffer: () -> Boolean,
bitstreamSupported: () -> Boolean,
@@ -160,7 +161,7 @@ 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)
Log.w(TAG, "IEC 61937 carrier probe failed; not offering the bitstream carrier", error)
false
}
@@ -169,7 +170,7 @@ private fun iecCarrierBitstreamSupported(): Boolean = try {
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)
Log.w(TAG, "IEC 61937 carrier probe failed; not offering the bitstream carrier", error)
false
}
@@ -177,7 +178,7 @@ private fun iecCarrierDirectPlaybackSupported(): Boolean = try {
private fun iecCarrierProbeFormat(): AudioFormat = AudioFormat.Builder()
.setEncoding(AudioFormat.ENCODING_IEC61937)
.setChannelMask(AudioFormat.CHANNEL_OUT_7POINT1_SURROUND)
.setSampleRate(TrueHdMatPacker.CARRIER_SAMPLE_RATE)
.setSampleRate(IecCarrier.SAMPLE_RATE)
.build()
private fun movieAudioAttributes(): android.media.AudioAttributes = AudioAttributes.Builder()
@@ -0,0 +1,308 @@
package com.edde746.plezy.exoplayer
import java.nio.ByteBuffer
/**
* Packs DTS-HD (Master Audio) access units into IEC 61937 "DTS type IV" bursts (#1988).
*
* Some HDMI routes advertise `ENCODING_DTS_HD` but cannot actually carry Master Audio: Amazon
* specifies the Fire TV Stick 4K Max as "DTS-HD passthrough, basic profile", and Android has one
* ambiguous encoding constant for both profiles until API 34. Handing media3's raw path full MA
* frames there initialises an AudioTrack that drains normally and renders silence. The same
* devices do bitstream the 192kHz/7.1 `ENCODING_IEC61937` carrier — the split Kodi models with
* its "AudioTrack (IEC)" sink, and the one TrueHD already rides here (#1804, #1863) — so DTS-HD
* is packed onto that carrier instead.
*
* The algorithm is a port of FFmpeg's `spdif_header_dts`/`spdif_header_dts4`
* (libavformat/spdifenc.c, n8.1) at `dtshd_rate=768000`, the rate that fills the 8-channel/192kHz
* carrier; Kodi's `CAEBitstreamPacker::PackDTSHD` produces the same bytes.
*
* Output is one complete IEC 61937 burst per access unit:
*
* - 8-byte preamble, little endian: `Pa=0xF872 Pb=0x4E1F Pc=0x11|subtype<<8 Pd=aligned bytes`
* - 10-byte DTS-HD start code, 16-bit big-endian payload size, then the access unit, all 16-bit
* byte-swapped
* - zero padding to the burst repetition period the core frame duration maps to
* (512 samples at 48kHz — the shape of essentially all Master Audio — is 32768 bytes)
*
* Unlike MAT's fixed frames, a Master Audio peak can genuinely exceed the carrier. FFmpeg answers
* by stripping such units to the always-fitting core substream and holding that for
* `dtshd_fallback_time` (60s) so receivers do not flap between core and MA decoding; that
* behavior is ported as-is and pinned by the golden fixture.
*
* Not thread safe; the sink drives it from the playback thread only.
*/
internal class DtsHdIecPacker : IecCarrierPacker {
internal companion object {
private const val BURST_HEADER_SIZE = 8
private const val SYNCWORD1 = 0xF872
private const val SYNCWORD2 = 0x4E1F
private const val IEC61937_DTSHD = 0x11
/** Core and extension-substream sync words, big-endian raw framing. */
private const val SYNC_CORE = 0x7FFE8001
private const val SYNC_EXSS = 0x64582025
/**
* Little-endian and 14-bit core framings, from S/PDIF and DTS-in-WAV captures. They have no
* period mapping here (FFmpeg's HD path refuses them too), so they latch the stream
* unsupported and it decodes.
*/
private const val SYNC_CORE_LE = 0xFE7F0180.toInt()
private const val SYNC_CORE_14B_BE = 0x1FFFE800
private const val SYNC_CORE_14B_LE = 0xFF1F00E8.toInt()
/**
* The carrier's IEC 60958 frame rate as FFmpeg's two-channel model counts it: the burst
* repetition period is `period = 768000 * coreSamples / coreRate` IEC 60958 frames of 4 bytes,
* which is the same byte rate as [IecCarrier]'s 8 channels at 192kHz.
*/
private const val CARRIER_IEC958_RATE = 768_000
/** Core SFREQ index to Hz (`ff_dca_sample_rates`); zero marks invalid indices. */
private val CORE_SAMPLE_RATES = intArrayOf(
0, 8000, 16000, 32000, 0, 0, 11025, 22050, 44100, 0, 0, 12000, 24000, 48000, 96000, 192000
)
/** Precedes every burst payload; `spdifenc.c`'s `dtshd_start_code`. */
private val START_CODE = byteArrayOf(0x01, 0, 0, 0, 0, 0, 0, 0, 0xFE.toByte(), 0xFE.toByte())
/** Seconds of core-only output after an overflow; FFmpeg's `dtshd_fallback_time` default. */
private const val HD_STRIP_SECONDS = 60
/** Minimum bytes needed to read a core header through its SFREQ field. */
private const val MIN_CORE_HEADER_LENGTH = 9
/** IEC 61937-11 subtype for a burst repetition period in IEC 60958 frames, or -1. */
private fun subtypeForPeriod(period: Long): Int = when (period) {
512L -> 0
1024L -> 1
2048L -> 2
4096L -> 3
8192L -> 4
16384L -> 5
else -> -1
}
private fun readSyncWord(data: ByteArray, offset: Int): Int = ((data[offset].toInt() and 0xFF) shl 24) or
((data[offset + 1].toInt() and 0xFF) shl 16) or
((data[offset + 2].toInt() and 0xFF) shl 8) or
(data[offset + 3].toInt() and 0xFF)
/** Reads [count] (≤ 24) bits big-endian starting at absolute bit [bitPosition]. */
private fun readBits(data: ByteArray, bitPosition: Int, count: Int): Int {
var result = 0
var position = bitPosition
var remaining = count
while (remaining > 0) {
val byte = data[position ushr 3].toInt() and 0xFF
val bitsLeftInByte = 8 - (position and 7)
val take = minOf(bitsLeftInByte, remaining)
result = (result shl take) or ((byte shr (bitsLeftInByte - take)) and ((1 shl take) - 1))
position += take
remaining -= take
}
return result
}
/** `NBLKS + 1`: PCM blocks of 32 samples in the core frame at [offset]. */
private fun coreBlocks(data: ByteArray, offset: Int): Int {
val word = ((data[offset + 4].toInt() and 0xFF) shl 8) or (data[offset + 5].toInt() and 0xFF)
return ((word shr 2) and 0x7F) + 1
}
/** `FSIZE + 1`: the core frame's byte length. */
private fun coreFrameSize(data: ByteArray, offset: Int): Int {
val bits = ((data[offset + 5].toInt() and 0xFF) shl 16) or
((data[offset + 6].toInt() and 0xFF) shl 8) or
(data[offset + 7].toInt() and 0xFF)
return ((bits shr 4) and 0x3FFF) + 1
}
/** The core `SFREQ` field mapped to Hz; 0 for reserved indices. */
private fun coreSampleRate(data: ByteArray, offset: Int): Int = CORE_SAMPLE_RATES[((data[offset + 8].toInt() and 0xFF) shr 2) and 0x0F]
/**
* Total byte length of the extension substream at [offset] (`nuBits4ExSSFsize + 1`), or 0
* when the header does not fit in [limit].
*
* Header layout after the 32-bit sync: UserDefinedBits(8), nExtSSIndex(2),
* bHeaderSizeType(1), then a header-size field of 8 or 12 bits and this size field of 16 or
* 20 bits — at most 75 bits, so 10 bytes cover every shape.
*/
private fun extensionSubstreamSize(data: ByteArray, offset: Int, limit: Int): Int {
if (offset + 10 > limit) return 0
val base = offset shl 3
val wide = readBits(data, base + 42, 1) == 1
val sizeFieldPosition = base + 43 + if (wide) 12 else 8
return readBits(data, sizeFieldPosition, if (wide) 20 else 16) + 1
}
}
/** Burst geometry learned from the stream's core framing; zero until the first unit packs. */
private var burstBytes = 0
private var burstBacking = Array(2) { ByteArray(0) }
private var burstBuffers = Array(2) { ByteBuffer.wrap(burstBacking[it]) }
private var burstIndex = 0
/** Bytes each reusable buffer was dirtied to, so padding only clears what a prior burst wrote. */
private val dirtyEnd = intArrayOf(0, 0)
/** Assembles start code + size + access unit before the byte swap; sized with the bursts. */
private var payloadScratch = ByteArray(0)
/** Access units still to strip to their core after an overflow. */
private var hdStripRemaining = 0
/**
* Latched when a burst had to be stripped to the core substream, so the sink can log the
* downgrade once. Cleared by [reset].
*/
var strippedToCore = false
private set
override var unsupportedStream = false
private set
override fun reset() {
// Buffers and the learned burst geometry survive; only per-stream state drops. The flag is
// re-learned from the next unit; leaving it latched would silence every later stream.
hdStripRemaining = 0
strippedToCore = false
unsupportedStream = false
}
override fun accessUnitLength(data: ByteArray, offset: Int, limit: Int): Int {
if (offset + 4 > limit) return 0
when (readSyncWord(data, offset)) {
SYNC_CORE -> Unit
SYNC_EXSS -> {
// A stray HD frame without its core, seen at stream starts. Its size is walkable, so it
// is consumed as a unit and dropped by packAccessUnit; FFmpeg discards these too.
val size = extensionSubstreamSize(data, offset, limit)
return if (size < 4 || offset + size > limit) 0 else size
}
SYNC_CORE_LE, SYNC_CORE_14B_BE, SYNC_CORE_14B_LE -> {
unsupportedStream = true
return 0
}
else -> return 0
}
if (offset + MIN_CORE_HEADER_LENGTH > limit) return 0
val coreSize = coreFrameSize(data, offset)
var end = offset + coreSize
if (coreSize < MIN_CORE_HEADER_LENGTH || end > limit) return 0
// Master Audio glues one or more extension substreams to the core; they belong to this unit.
while (end + 4 <= limit && readSyncWord(data, end) == SYNC_EXSS) {
val size = extensionSubstreamSize(data, end, limit)
if (size < 4 || end + size > limit) return 0
end += size
}
return end - offset
}
override fun packAccessUnit(data: ByteArray, offset: Int, length: Int): ByteBuffer? {
if (length < MIN_CORE_HEADER_LENGTH) return null
when (readSyncWord(data, offset)) {
SYNC_CORE -> Unit
// The stray leading HD frame accessUnitLength admitted; there is no core to derive a
// period from, so it is dropped rather than carried.
SYNC_EXSS -> return null
else -> return null
}
val blocks = coreBlocks(data, offset)
val coreSize = coreFrameSize(data, offset)
val sampleRate = coreSampleRate(data, offset)
if (sampleRate == 0) {
unsupportedStream = true
return null
}
val coreSamples = blocks shl 5
val periodProduct = CARRIER_IEC958_RATE.toLong() * coreSamples
val period = periodProduct / sampleRate
val newSubtype = if (periodProduct % sampleRate != 0L) -1 else subtypeForPeriod(period)
if (newSubtype < 0) {
// 44.1kHz-family cores and exotic frame lengths map to no IEC 61937-11 repetition period at
// this carrier rate; FFmpeg refuses them the same way, so the stream decodes instead.
unsupportedStream = true
return null
}
setBurstGeometry((period * 4).toInt())
// FFmpeg's overflow answer: a Master Audio peak the carrier cannot hold strips this and the
// next ~60 seconds of units to the always-fitting core substream, so the receiver does not
// flap between core and MA decoding.
if (START_CODE.size + 2 + length > burstBytes - BURST_HEADER_SIZE) {
hdStripRemaining = sampleRate * HD_STRIP_SECONDS / coreSamples
}
var payloadSize = length
if (hdStripRemaining > 0 && coreSize <= length) {
payloadSize = coreSize
hdStripRemaining--
strippedToCore = true
}
val payloadBytes = START_CODE.size + 2 + payloadSize
if (payloadBytes > burstBytes - BURST_HEADER_SIZE) {
// Even the bare core overflows this period (only possible for very short core frames);
// nothing can be carried.
unsupportedStream = true
return null
}
START_CODE.copyInto(payloadScratch, 0)
payloadScratch[START_CODE.size] = (payloadSize ushr 8).toByte()
payloadScratch[START_CODE.size + 1] = (payloadSize and 0xFF).toByte()
data.copyInto(payloadScratch, START_CODE.size + 2, offset, offset + payloadSize)
// A final lone byte goes out MSB-aligned; its swap partner must be zero, not stale scratch.
if (payloadBytes and 1 == 1) payloadScratch[payloadBytes] = 0
burstIndex = burstIndex xor 1
val backing = burstBacking[burstIndex]
putLittleEndianShort(backing, 0, SYNCWORD1)
putLittleEndianShort(backing, 2, SYNCWORD2)
putLittleEndianShort(backing, 4, IEC61937_DTSHD or (newSubtype shl 8))
// Aligned so (Pd & 0xF) == 0x8, which some receivers reportedly require; FFmpeg and Kodi
// both apply the same quirk.
putLittleEndianShort(backing, 6, ((payloadBytes + 0x17) and 0x0F.inv()) - BURST_HEADER_SIZE)
// The carrier is a 16-bit sample stream, so the payload goes out byte-swapped per word.
var source = 0
var destination = BURST_HEADER_SIZE
val swappedPayload = payloadBytes + (payloadBytes and 1)
while (source < swappedPayload) {
backing[destination] = payloadScratch[source + 1]
backing[destination + 1] = payloadScratch[source]
source += 2
destination += 2
}
if (destination < dirtyEnd[burstIndex]) {
java.util.Arrays.fill(backing, destination, dirtyEnd[burstIndex], 0)
}
dirtyEnd[burstIndex] = destination
val burst = burstBuffers[burstIndex]
burst.limit(burstBytes)
burst.position(0)
return burst
}
private fun setBurstGeometry(newBurstBytes: Int) {
if (newBurstBytes == burstBytes) return
burstBytes = newBurstBytes
burstBacking = Array(2) { ByteArray(newBurstBytes) }
burstBuffers = Array(2) { ByteBuffer.wrap(burstBacking[it]).order(java.nio.ByteOrder.LITTLE_ENDIAN) }
dirtyEnd[0] = 0
dirtyEnd[1] = 0
payloadScratch = ByteArray(newBurstBytes)
}
private fun putLittleEndianShort(target: ByteArray, offset: Int, value: Int) {
target[offset] = (value and 0xFF).toByte()
target[offset + 1] = ((value shr 8) and 0xFF).toByte()
}
}
@@ -0,0 +1,50 @@
package com.edde746.plezy.exoplayer
import java.nio.ByteBuffer
/**
* The IEC 61937 carrier tuple every packed bitstream rides: 192kHz, 7.1, PCM-16 shaped.
*
* This is the high-bitrate HDMI shape Kodi uses for both TrueHD/MAT and DTS-HD Master Audio, and
* the one [supportsIecCarrier] probes. The 44.1kHz family would need a 176.4kHz sibling, which is
* deliberately not built; streams from that family decode instead.
*/
internal object IecCarrier {
const val SAMPLE_RATE = 192_000
const val CHANNEL_COUNT = 8
const val BYTES_PER_FRAME = CHANNEL_COUNT * 2
}
/**
* Splits one codec's bitstream into access units and packs them into IEC 61937 bursts for
* [IecCarrierSink].
*
* Implementations are not thread safe; the sink drives them from the playback thread only.
*/
internal interface IecCarrierPacker {
/**
* Length in bytes of the access unit starting at [offset], or 0 when no unit boundary is
* recognised there. May latch [unsupportedStream] when the boundary is recognisable but names a
* framing this carrier cannot ride.
*/
fun accessUnitLength(data: ByteArray, offset: Int, limit: Int): Int
/**
* Packs one access unit, returning a completed burst or null when this unit did not finish one.
*
* Returned buffers are owned by the packer and reused, alternating between two so a burst handed
* downstream stays valid while the next one fills. Callers must submit or copy it before the
* second following call.
*/
fun packAccessUnit(data: ByteArray, offset: Int, length: Int): ByteBuffer?
/**
* True when the bitstream announced a shape this carrier cannot ride. The packer emits nothing
* in that state; the sink latches the stream onto the decoder instead.
*/
val unsupportedStream: Boolean
/** Drops all carrier state. Called on flush/seek: bursts must not straddle a discontinuity. */
fun reset()
}
@@ -16,13 +16,16 @@ import java.nio.ByteBuffer
import java.util.concurrent.atomic.AtomicInteger
/**
* Routes Dolby TrueHD through a MAT/IEC 61937 carrier, and everything else through the normal sink
* (#1804).
* Routes Dolby TrueHD (#1804) and DTS-HD Master Audio (#1988) through an IEC 61937 carrier, and
* everything else through the normal sink.
*
* 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.
* Android will not bitstream raw TrueHD on the TV routes measured for #1804: the platform reports
* `ENCODING_DOLBY_TRUEHD` as offload-only while reporting `ENCODING_IEC61937` at 192kHz/7.1 as
* bitstream-capable. Raw `ENCODING_DTS_HD` is worse on Fire OS: the route advertises it, the
* AudioTrack initialises and drains, and the receiver hears silence, because the encoding only
* means "basic profile" there (#1988). Kodi models the same split and packs the carrier itself;
* media3 only ever hands Android the raw encodings, at the stream rate. This sink adds the
* missing path, with one [IecCarrierPacker] per codec.
*
* **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,
@@ -39,7 +42,7 @@ import java.util.concurrent.atomic.AtomicInteger
* delegates so either can be activated later; per-stream calls go to the active one alone.
*/
@OptIn(UnstableApi::class)
internal class TrueHdCarrierSink(
internal class IecCarrierSink(
private val defaultSink: AudioSink,
private val carrierSink: AudioSink,
/** Whether the current route can bitstream the carrier tuple. Evaluated per format. */
@@ -49,39 +52,39 @@ internal class TrueHdCarrierSink(
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 matPacker = TrueHdMatPacker()
private val dtsHdPacker = DtsHdIecPacker()
private val packer = TrueHdMatPacker()
/** The packer for the configured stream; chosen by mime type in [configure]. */
private var activePacker: IecCarrierPacker = matPacker
private var active: AudioSink = defaultSink
private var carrierActive = false
private var loggedCoreStrip = 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.
* Anchor for carrier timestamps, and how many carrier frames 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.
* Every burst is a whole number of carrier frames, so timestamps are derived from the cadence
* rather than from whichever access unit happened to close the burst. 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 burst. Counting frames rather than bursts
* keeps the arithmetic exact for burst durations that are not whole microseconds (a DTS-HD
* burst is 10666.67us).
*/
private var carrierAnchorUs: Long = C.TIME_UNSET
private var burstsSinceAnchor: Long = 0
private var carrierFramesSinceAnchor: Long = 0
private var playbackParameters: PlaybackParameters = PlaybackParameters.DEFAULT
private var sinkListener: AudioSink.Listener? = null
/**
* Latched when a stream's bitstream contradicts the rate its container announced, which selection
* was made from.
* Latched when a stream's bitstream contradicts the shape its container announced, which
* selection was made from.
*
* Deliberately outlives [flush] and [reset]: media3 resets every renderer disabled by a new
* selection before enabling the replacement (ExoPlayerImplInternal.enableRenderers), and both
@@ -114,11 +117,11 @@ internal class TrueHdCarrierSink(
* [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.
* from the bitstream 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 (!isTrueHd(format) && !isDtsHd(format)) return false
if (mismatchGeneration == mediaGeneration.get()) return false
if (!isCarrierRateFamily(format.sampleRate)) return false
if (playbackParameters.speed != 1f) return false
@@ -136,15 +139,15 @@ internal class TrueHdCarrierSink(
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)
.setChannelCount(IecCarrier.CHANNEL_COUNT)
.setSampleRate(IecCarrier.SAMPLE_RATE)
.build()
/**
* 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
* configuration #1804 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
@@ -153,15 +156,30 @@ internal class TrueHdCarrierSink(
*/
private fun isTrueHd(format: Format): Boolean = format.sampleMimeType == MimeTypes.AUDIO_TRUEHD
/**
* DTS-HD is binary only while the carrier route exists: the carrier, or decoded PCM. Falling
* through to media3's raw `ENCODING_DTS_HD` path on such a route would land on exactly the
* silent configuration #1988 is about the route that advertises the carrier and raw DTS-HD at
* once is the one whose raw path renders silence. Without a carrier route the raw path is the
* pre-carrier behavior and is left alone: those routes never had the carrier to lose, and some
* of them bitstream raw DTS-HD genuinely.
*
* DTS Express deliberately stays off the carrier: its mime carries a `;profile=lbr` suffix, so
* the equality below excludes it and it keeps decoding as before.
*/
private fun isDtsHd(format: Format): Boolean = format.sampleMimeType == MimeTypes.AUDIO_DTS_HD
override fun supportsFormat(format: Format): Boolean = when {
shouldUseCarrier(format) -> true
isTrueHd(format) -> false
isDtsHd(format) && carrierRouteAvailable() -> 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
isDtsHd(format) && carrierRouteAvailable() -> AudioSink.SINK_FORMAT_UNSUPPORTED
else -> defaultSink.getFormatSupport(format)
}
@@ -173,16 +191,18 @@ internal class TrueHdCarrierSink(
"info",
"audio",
if (useCarrier) {
"TrueHD via MAT/IEC 61937 carrier at ${TrueHdMatPacker.CARRIER_SAMPLE_RATE}Hz/" +
"${TrueHdMatPacker.CARRIER_CHANNEL_COUNT}ch"
"${carrierCodecName(inputFormat)} via IEC 61937 carrier at ${IecCarrier.SAMPLE_RATE}Hz/" +
"${IecCarrier.CHANNEL_COUNT}ch"
} else {
"Leaving the MAT carrier; audio returns to the normal sink"
"Leaving the IEC 61937 carrier; audio returns to the normal sink"
}
)
}
carrierActive = useCarrier
configuredGeneration = mediaGeneration.get()
activePacker = if (isDtsHd(inputFormat)) dtsHdPacker else matPacker
active = if (useCarrier) carrierSink else defaultSink
loggedCoreStrip = false
discardCarrierState()
if (useCarrier) {
@@ -197,6 +217,8 @@ internal class TrueHdCarrierSink(
}
}
private fun carrierCodecName(format: Format): String = if (isDtsHd(format)) "DTS-HD" else "TrueHD (MAT)"
override fun handleBuffer(buffer: ByteBuffer, presentationTimeUs: Long, encodedAccessUnitCount: Int): Boolean {
if (!carrierActive) return defaultSink.handleBuffer(buffer, presentationTimeUs, encodedAccessUnitCount)
@@ -217,37 +239,30 @@ internal class TrueHdCarrierSink(
var offset = 0
while (offset < remaining.size) {
val length = TrueHdMatPacker.accessUnitLength(remaining, offset, remaining.size)
val length = activePacker.accessUnitLength(remaining, offset, remaining.size)
if (activePacker.unsupportedStream) return latchMismatch()
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)
if (packer.unsupportedRateFamily) {
// Selection is made from Format.sampleRate, so the bitstream disagrees with its container.
// The packer emits nothing in that state; consuming here would turn the stream into
// silence. Leave this unit in the buffer, latch the carrier off, and ask for reselection so
// the decoder takes over and receives it.
if (mismatchGeneration != configuredGeneration) {
mismatchGeneration = configuredGeneration
log?.invoke(
"warn",
"audio",
"TrueHD bitstream announced a 44.1kHz-family rate its container did not; " +
"leaving the carrier so it decodes"
)
sinkListener?.onAudioCapabilitiesChanged()
}
return false
}
val burst = activePacker.packAccessUnit(remaining, offset, length)
if (activePacker.unsupportedStream) return latchMismatch()
offset += length
buffer.position(buffer.position() + length)
if (burst == null) continue
if (activePacker === dtsHdPacker && dtsHdPacker.strippedToCore && !loggedCoreStrip) {
loggedCoreStrip = true
log?.invoke(
"warn",
"audio",
"DTS-HD MA bitrate exceeds the IEC 61937 carrier; sending the DTS core substream for ~60s"
)
}
val burstTimeUs = carrierAnchorUs + burstsSinceAnchor * CARRIER_BURST_DURATION_US
burstsSinceAnchor++
val burstTimeUs = carrierAnchorUs + carrierFramesSinceAnchor * 1_000_000L / IecCarrier.SAMPLE_RATE
carrierFramesSinceAnchor += burst.remaining().toLong() / IecCarrier.BYTES_PER_FRAME
if (!carrierSink.handleBuffer(burst, burstTimeUs, 1)) {
pendingBurst = burst
pendingBurstTimeUs = burstTimeUs
@@ -257,10 +272,35 @@ internal class TrueHdCarrierSink(
return true
}
/**
* The bitstream announced a shape the carrier cannot ride, which selection could not see in the
* container's Format. The packer emits nothing in that state; consuming here would turn the
* stream into silence. Leave the unit in the buffer, latch the carrier off, and ask for
* reselection so the decoder takes over and receives it.
*/
private fun latchMismatch(): Boolean {
if (mismatchGeneration != configuredGeneration) {
mismatchGeneration = configuredGeneration
log?.invoke(
"warn",
"audio",
if (activePacker === dtsHdPacker) {
"DTS-HD bitstream cannot ride the 192kHz carrier (44.1kHz-family core or non-standard framing); " +
"leaving the carrier so it decodes"
} else {
"TrueHD bitstream announced a 44.1kHz-family rate its container did not; " +
"leaving the carrier so it decodes"
}
)
sinkListener?.onAudioCapabilitiesChanged()
}
return false
}
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.
// A partially filled burst cannot be emitted; up to one burst is dropped at the end of a stream.
active.playToEndOfStream()
}
@@ -285,11 +325,12 @@ internal class TrueHdCarrierSink(
override fun getAudioTrackBufferSizeUs(): Long = active.getAudioTrackBufferSizeUs()
private fun discardCarrierState() {
packer.reset()
matPacker.reset()
dtsHdPacker.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
carrierFramesSinceAnchor = 0
}
// --- Persistent state: mirrored, so either delegate can be activated later ---
@@ -321,23 +362,23 @@ internal class TrueHdCarrierSink(
if (isUnitSpeed) playbackParameters else PlaybackParameters.DEFAULT
)
// Crossing 1x changes whether TrueHD may ride the carrier, but nothing re-asks on its own:
// the renderer only consults the sink when capabilities are invalidated. Rebuilding the track
// selector parameters is not enough either — DefaultTrackSelector skips invalidation when the
// rebuilt parameters compare equal. This is the path media3 itself uses for a route change,
// and it reaches onRendererCapabilitiesChanged, so the format is re-evaluated and TrueHD moves
// between the carrier and the decoder.
// Crossing 1x changes whether a bitstream may ride the carrier, but nothing re-asks on its
// own: the renderer only consults the sink when capabilities are invalidated. Rebuilding the
// track selector parameters is not enough either — DefaultTrackSelector skips invalidation
// when the rebuilt parameters compare equal. This is the path media3 itself uses for a route
// change, and it reaches onRendererCapabilitiesChanged, so the format is re-evaluated and the
// stream moves between the carrier and the decoder.
if (wasUnitSpeed != isUnitSpeed && (carrierActive || isUnitSpeed)) {
log?.invoke(
"info",
"audio",
"Playback speed ${if (isUnitSpeed) "returned to" else "left"} 1.0x; re-evaluating the TrueHD carrier"
"Playback speed ${if (isUnitSpeed) "returned to" else "left"} 1.0x; re-evaluating the bitstream carrier"
)
sinkListener?.onAudioCapabilitiesChanged()
}
}
/** Whether TrueHD is currently riding the carrier. Read by the core when speed changes. */
/** Whether the configured stream is currently riding the carrier. */
val isCarrierActive: Boolean
get() = carrierActive
@@ -345,7 +386,7 @@ internal class TrueHdCarrierSink(
* While the carrier is live the delegate is deliberately pinned to 1x, but the player polls this
* through the media clock and adopts whatever it reads. Reporting the delegate's value would push
* the pinned 1x back and silently undo the user's speed change, so the requested parameters are
* reported instead; the reselection triggered above then moves TrueHD onto the decoder, which
* reported instead; the reselection triggered above then moves the stream onto the decoder, which
* really can apply them.
*/
override fun getPlaybackParameters(): PlaybackParameters = if (carrierActive) playbackParameters else active.getPlaybackParameters()
@@ -407,7 +448,7 @@ internal class TrueHdCarrierSink(
/**
* Called by the owner immediately before a new media item is set, which is the only point where
* "this stream lied about its rate" stops being true and the carrier can be offered again.
* "this stream lied about its shape" stops being true and the carrier can be offered again.
*/
fun beginMediaItem() {
mediaGeneration.incrementAndGet()
@@ -151,14 +151,14 @@ class PlezyRenderersFactory(context: Context) : DefaultRenderersFactory(context)
)
}
private var trueHdCarrierSink: TrueHdCarrierSink? = null
private var iecCarrierSink: IecCarrierSink? = null
/**
* Clears per-stream carrier state that must survive renderer resets but not a new media item.
* Call before setting a new source; see [TrueHdCarrierSink.beginMediaItem].
* Call before setting a new source; see [IecCarrierSink.beginMediaItem].
*/
fun beginMediaItem() {
trueHdCarrierSink?.beginMediaItem()
iecCarrierSink?.beginMediaItem()
}
override fun buildAudioSink(
@@ -204,17 +204,17 @@ class PlezyRenderersFactory(context: Context) : DefaultRenderersFactory(context)
audioDiagnosticsLogger
)
return TrueHdCarrierSink(
return IecCarrierSink(
defaultSink = processedSink,
carrierSink = buildCarrierSink(context, bufferSizeProvider),
carrierRouteAvailable = { supportsTrueHdMatCarrier() },
carrierRouteAvailable = { supportsIecCarrier() },
directOutputBlocked = { format -> shouldBlockDirectAudioOutput?.invoke(format) == true },
log = audioDiagnosticsLogger
).also { trueHdCarrierSink = it }
).also { iecCarrierSink = it }
}
/**
* The delegate that carries packed TrueHD (#1804).
* The delegate that carries packed TrueHD and DTS-HD (#1804, #1988).
*
* Deliberately separate from the processed sink, and deliberately barren: an empty
* [DefaultAudioSink.AudioProcessorChain] means no downmix, no Sonic, no silence skipping and no
@@ -24,7 +24,7 @@ import java.nio.ByteBuffer
*
* Not thread safe; the sink drives it from the playback thread only.
*/
internal class TrueHdMatPacker {
internal class TrueHdMatPacker : IecCarrierPacker {
internal companion object {
/** Payload bytes in one MAT frame. */
@@ -33,11 +33,6 @@ internal class TrueHdMatPacker {
/** Bytes from the start of one burst to the next, including preamble and trailing gap. */
const val MAT_PKT_OFFSET = 61440
/** Carrier the packed stream must be played at. */
const val CARRIER_SAMPLE_RATE = 192_000
const val CARRIER_CHANNEL_COUNT = 8
const val CARRIER_BYTES_PER_FRAME = CARRIER_CHANNEL_COUNT * 2
private const val BURST_HEADER_SIZE = 8
private const val SYNCWORD1 = 0xF872
private const val SYNCWORD2 = 0x4E1F
@@ -99,7 +94,7 @@ internal class TrueHdMatPacker {
private var samplesPerFrame = 0
/** Drops all carrier state. Call on flush/seek: MAT frames must not straddle a discontinuity. */
fun reset() {
override fun reset() {
matBufferIndex = 0
matBufferFilled = 0
previousTiming = 0
@@ -107,7 +102,7 @@ internal class TrueHdMatPacker {
samplesPerFrame = 0
// The family is re-learned from the next major sync. Leaving it latched here would make every
// later stream on this packer emit nothing.
unsupportedRateFamily = false
unsupportedStream = false
java.util.Arrays.fill(matBuffers[0], 0)
java.util.Arrays.fill(matBuffers[1], 0)
}
@@ -119,9 +114,11 @@ internal class TrueHdMatPacker {
* whole path is built around. Rather than carry a second carrier configuration for a combination
* that is essentially absent from real media, the sink reads this and falls back to decoding.
*/
var unsupportedRateFamily: Boolean = false
override var unsupportedStream: Boolean = false
private set
override fun accessUnitLength(data: ByteArray, offset: Int, limit: Int): Int = Companion.accessUnitLength(data, offset, limit)
/**
* Packs one access unit, returning a completed burst when this unit finished a MAT frame.
*
@@ -137,7 +134,7 @@ internal class TrueHdMatPacker {
* At most one burst can complete per access unit: padding is bounded below half a MAT frame and
* an access unit is far smaller, so a single unit cannot span two frame boundaries.
*/
fun packAccessUnit(data: ByteArray, offset: Int, length: Int): ByteBuffer? {
override fun packAccessUnit(data: ByteArray, offset: Int, length: Int): ByteBuffer? {
if (length < MIN_ACCESS_UNIT_LENGTH) return null
if (hasMajorSync(data, offset, offset + length)) {
@@ -148,11 +145,11 @@ internal class TrueHdMatPacker {
else -> return null
}
// Bit 3 selects the 44.1kHz family, which rides a 176.4kHz carrier we do not build.
unsupportedRateFamily = (rateBits and 8) != 0
if (unsupportedRateFamily) return null
unsupportedStream = (rateBits and 8) != 0
if (unsupportedStream) return null
samplesPerFrame = 40 shl (rateBits and 3)
}
if (unsupportedRateFamily || samplesPerFrame == 0) return null
if (unsupportedStream || samplesPerFrame == 0) return null
val inputTiming = ((data[offset + 2].toInt() and 0xFF) shl 8) or (data[offset + 3].toInt() and 0xFF)
var paddingRemaining = 0
@@ -88,7 +88,7 @@ class AudioOutputPolicyTest {
// 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(
iecCarrierSupported(
sdkInt = 28,
canSizeCarrierBuffer = { true },
bitstreamSupported = { true },
@@ -102,7 +102,7 @@ class AudioOutputPolicyTest {
for (sdkInt in intArrayOf(29, 30, 32, 33, 34)) {
assertFalse(
"api $sdkInt",
trueHdMatCarrierSupported(
iecCarrierSupported(
sdkInt = sdkInt,
canSizeCarrierBuffer = { false },
bitstreamSupported = { true },
@@ -120,7 +120,7 @@ class AudioOutputPolicyTest {
assertEquals(
"api $sdkInt supported=$supported",
supported,
trueHdMatCarrierSupported(
iecCarrierSupported(
sdkInt = sdkInt,
canSizeCarrierBuffer = { true },
bitstreamSupported = { throw AssertionError("getDirectPlaybackSupport does not exist below API 33") },
@@ -139,7 +139,7 @@ class AudioOutputPolicyTest {
assertEquals(
"supported=$supported",
supported,
trueHdMatCarrierSupported(
iecCarrierSupported(
sdkInt = 33,
canSizeCarrierBuffer = { true },
bitstreamSupported = { supported },
@@ -0,0 +1,270 @@
package com.edde746.plezy.exoplayer
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Pins the DTS-HD -> IEC 61937 "DTS type IV" carrier against FFmpeg (#1988).
*
* The carrier is fed straight to an `ENCODING_IEC61937` AudioTrack, so a wrong byte is not a subtle
* defect: the receiver either drops sync or renders the carrier as full-scale noise. The only
* defensible bar is byte-for-byte agreement with a reference implementation, so the fixtures are
* FFmpeg's own input and output at the 768kHz HD rate that fills the 192kHz/7.1 carrier.
*
* FFmpeg cannot encode Master Audio, so the access units are synthesized — real `dca` core frames,
* each glued to a hand-built extension substream — by `dtshd_fixture_generator.py` beside the
* fixtures; the golden is then FFmpeg's own packing:
*
* ```
* ffmpeg -f dts -i dtshd_ma_access_units.bin -c:a copy -f spdif -dtshd_rate 768000 \
* dtshd_ma_iec61937_golden.bin
* ```
*
* One mid-stream unit is oversized on purpose, so the fixtures also pin FFmpeg's overflow answer:
* strip to the core substream and hold that for ~60s.
*/
class DtsHdIecPackerTest {
private fun resource(name: String): ByteArray = checkNotNull(javaClass.classLoader?.getResourceAsStream(name)) { "missing fixture $name" }
.use { it.readBytes() }
private val accessUnits by lazy { resource("dtshd_ma_access_units.bin") }
private val golden by lazy { resource("dtshd_ma_iec61937_golden.bin") }
/** 512 samples at the fixture's 48kHz core rate on the 768kHz carrier model, in bytes. */
private val burstSize = 32768
/** The whole point: our carrier and FFmpeg's are the same bytes. */
@Test
fun packedCarrierMatchesFfmpegByteForByte() {
val packed = packAll(accessUnits)
assertEquals(
"burst count differs from FFmpeg",
golden.size / burstSize,
packed.size / burstSize
)
// Compare per burst so a failure names the burst rather than dumping 480KB.
for (index in 0 until packed.size / burstSize) {
val from = index * burstSize
val to = from + burstSize
assertArrayEquals(
"burst $index differs from FFmpeg",
golden.copyOfRange(from, to),
packed.copyOfRange(from, to)
)
}
}
/** Each burst is a full IEC 61937 frame: preamble, then payload, then zero padding. */
@Test
fun everyBurstCarriesTheDtsHdPreamble() {
val packed = packAll(accessUnits)
for (index in 0 until packed.size / burstSize) {
val at = index * burstSize
assertEquals("burst $index Pa", 0xF872, readLittleEndianShort(packed, at))
assertEquals("burst $index Pb", 0x4E1F, readLittleEndianShort(packed, at + 2))
// Data type 0x11 with subtype 4: an 8192 IEC 60958 frame repetition period.
assertEquals("burst $index Pc (IEC61937_DTSHD)", 0x0411, readLittleEndianShort(packed, at + 4))
val payloadSize = 10 + 2 + burstAccessUnitSize(packed, at)
assertEquals(
"burst $index Pd must carry FFmpeg's (length & 0xf) == 0x8 alignment",
((payloadSize + 0x17) and 0x0F.inv()) - 8,
readLittleEndianShort(packed, at + 6)
)
}
}
/**
* A Master Audio peak the carrier cannot hold is stripped to the always-fitting core substream,
* and stays stripped for ~60s so the receiver does not flap between core and MA decoding. The
* fixture's fifth unit is oversized on purpose; everything after it is core-only.
*/
@Test
fun anOversizedUnitStripsToTheCoreAndHolds() {
val packer = DtsHdIecPacker()
val packed = packAll(accessUnits, packer)
val fullUnit = 3904
val coreOnly = 1884
for (index in 0 until packed.size / burstSize) {
val expected = if (index < 4) fullUnit else coreOnly
assertEquals("burst $index payload size", expected, burstAccessUnitSize(packed, index * burstSize))
}
assertTrue("the strip must be observable for logging", packer.strippedToCore)
assertFalse("stripping is a downgrade, not an unsupported stream", packer.unsupportedStream)
}
/** Core frames and their glued extension substreams split as one access unit each. */
@Test
fun accessUnitsSpanTheCoreAndItsExtensionSubstreams() {
val packer = DtsHdIecPacker()
var units = 0
var offset = 0
while (offset < accessUnits.size) {
val length = packer.accessUnitLength(accessUnits, offset, accessUnits.size)
if (length == 0) break
units++
offset += length
}
assertEquals("every byte of the fixture belongs to a unit", accessUnits.size, offset)
assertEquals(golden.size / burstSize, units)
}
/**
* Streams with core sometimes open with a stray HD frame that has no core (FFmpeg discards
* these). It must be consumed as a unit so the stream keeps moving, but never carried: there is
* no core to derive the burst period from.
*/
@Test
fun aStrayLeadingExtensionSubstreamIsConsumedButNotCarried() {
val packer = DtsHdIecPacker()
val firstUnit = packer.accessUnitLength(accessUnits, 0, accessUnits.size)
val exssSize = 2020
val stray = accessUnits.copyOfRange(firstUnit - exssSize, firstUnit)
val length = packer.accessUnitLength(stray, 0, stray.size)
assertEquals("the stray frame's own size field walks it", exssSize, length)
assertNull("a coreless frame cannot ride the carrier", packer.packAccessUnit(stray, 0, length))
assertFalse("a stray frame is dropped, not latched", packer.unsupportedStream)
}
/** The wide (bHeaderSizeType=1) extension-substream header carries 20-bit size fields. */
@Test
fun wideHeaderExtensionSubstreamSizesAreParsed() {
val packer = DtsHdIecPacker()
val core = accessUnits.copyOfRange(0, 1884)
val exssSize = 4100
val unit = core + wideHeaderExtensionSubstream(exssSize)
assertEquals(1884 + exssSize, packer.accessUnitLength(unit, 0, unit.size))
}
/**
* Little-endian and 14-bit core framings (S/PDIF and DTS-in-WAV captures) have no period mapping
* on this carrier; they must latch the stream unsupported so the sink hands it to the decoder,
* not silently consume it.
*/
@Test
fun nonBigEndianCoreFramingLatchesTheStreamUnsupported() {
for (sync in listOf(
byteArrayOf(0xFE.toByte(), 0x7F, 0x01, 0x80.toByte()),
byteArrayOf(0x1F, 0xFF.toByte(), 0xE8.toByte(), 0x00),
byteArrayOf(0xFF.toByte(), 0x1F, 0x00, 0xE8.toByte())
)) {
val packer = DtsHdIecPacker()
val buffer = sync + ByteArray(64)
assertEquals(0, packer.accessUnitLength(buffer, 0, buffer.size))
assertTrue("sync ${sync.joinToString { "%02x".format(it) }}", packer.unsupportedStream)
}
}
/** A 44.1kHz-family core maps to no IEC 61937-11 repetition period at 192kHz; it must decode. */
@Test
fun aFortyFourFamilyCoreLatchesTheStreamUnsupported() {
val packer = DtsHdIecPacker()
val unit = accessUnits.copyOfRange(0, 1884)
// SFREQ sits in bits [5:2] of byte 8; index 8 is 44100Hz.
unit[8] = ((unit[8].toInt() and 0b11000011) or (8 shl 2)).toByte()
assertNull(packer.packAccessUnit(unit, 0, unit.size))
assertTrue(packer.unsupportedStream)
}
/**
* The flags gate every later call, so leaving them latched across a reset would make a packer
* that once saw a bad stream emit nothing (or log strips) for the rest of its life.
*/
@Test
fun resetClearsTheLatchesAndReproducesTheStream() {
val packer = DtsHdIecPacker()
val leSync = byteArrayOf(0xFE.toByte(), 0x7F, 0x01, 0x80.toByte()) + ByteArray(64)
packer.accessUnitLength(leSync, 0, leSync.size)
assertTrue(packer.unsupportedStream)
packer.reset()
assertFalse("reset must clear the unsupported latch", packer.unsupportedStream)
assertFalse("reset must clear the strip latch", packer.strippedToCore)
assertArrayEquals(
"a clean packer must reproduce the stream after a poisoned one",
golden,
packAll(accessUnits, packer)
)
}
/** The packer must not allocate a burst per unit; buffers alternate and are reused. */
@Test
fun burstBuffersAreReusedRatherThanAllocated() {
val packer = DtsHdIecPacker()
val seen = java.util.IdentityHashMap<java.nio.ByteBuffer, Boolean>()
var bursts = 0
var offset = 0
while (offset < accessUnits.size) {
val length = packer.accessUnitLength(accessUnits, offset, accessUnits.size)
if (length == 0) break
packer.packAccessUnit(accessUnits, offset, length)?.let {
seen[it] = true
bursts++
}
offset += length
}
assertTrue("expected multiple bursts", bursts > 2)
assertEquals("buffers must alternate between exactly two", 2, seen.size)
}
private fun packAll(units: ByteArray, packer: DtsHdIecPacker = DtsHdIecPacker()): ByteArray {
val out = java.io.ByteArrayOutputStream()
var offset = 0
while (offset < units.size) {
val length = packer.accessUnitLength(units, offset, units.size)
if (length == 0) break
packer.packAccessUnit(units, offset, length)?.let { burst ->
val copy = ByteArray(burst.remaining())
burst.duplicate().get(copy)
out.write(copy)
}
offset += length
}
return out.toByteArray()
}
/** The 16-bit big-endian size field after the start code, read from the byte-swapped burst. */
private fun burstAccessUnitSize(packed: ByteArray, burstOffset: Int): Int {
// Payload bytes 10 and 11 land swapped within their 16-bit word: 10 -> +19, 11 -> +18.
val high = packed[burstOffset + 8 + 11].toInt() and 0xFF
val low = packed[burstOffset + 8 + 10].toInt() and 0xFF
return (high shl 8) or low
}
/** An extension substream whose header uses the wide 12/20-bit size fields. */
private fun wideHeaderExtensionSubstream(size: Int): ByteArray {
val frame = ByteArray(size)
frame[0] = 0x64
frame[1] = 0x58
frame[2] = 0x20
frame[3] = 0x25
// After UserDefinedBits(8): nExtSSIndex(2)=0, bHeaderSizeType(1)=1, then 12 header-size bits
// and 20 frame-size bits, all values stored minus one.
var bits = 0L
bits = (bits shl 2) or 0L
bits = (bits shl 1) or 1L
bits = (bits shl 12) or (32L - 1)
bits = (bits shl 20) or (size.toLong() - 1)
// 35 bits, MSB-aligned into bytes 5..9.
val aligned = bits shl (40 - 35)
for (i in 0 until 5) {
frame[5 + i] = ((aligned shr (32 - 8 * i)) and 0xFF).toByte()
}
return frame
}
private fun readLittleEndianShort(data: ByteArray, offset: Int): Int = (data[offset].toInt() and 0xFF) or ((data[offset + 1].toInt() and 0xFF) shl 8)
}
@@ -24,13 +24,14 @@ import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Routing and back-pressure contract for the TrueHD MAT carrier (#1804).
* Routing and back-pressure contract for the IEC 61937 carrier: TrueHD/MAT (#1804) and DTS-HD
* (#1988).
*
* The carrier is bit-exact, so the two failure modes that matter here are losing bytes and letting
* the wrong sink handle TrueHD.
* the wrong sink handle a carrier codec.
*/
@OptIn(UnstableApi::class)
class TrueHdCarrierSinkTest {
class IecCarrierSinkTest {
private val accessUnits: ByteArray =
checkNotNull(javaClass.classLoader?.getResourceAsStream("truehd_access_units.bin"))
@@ -40,12 +41,28 @@ class TrueHdCarrierSinkTest {
checkNotNull(javaClass.classLoader?.getResourceAsStream("truehd_iec61937_golden.bin"))
.use { it.readBytes() }
private val dtsHdAccessUnits: ByteArray by lazy {
checkNotNull(javaClass.classLoader?.getResourceAsStream("dtshd_ma_access_units.bin"))
.use { it.readBytes() }
}
private val dtsHdGolden: ByteArray by lazy {
checkNotNull(javaClass.classLoader?.getResourceAsStream("dtshd_ma_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 dtsHdFormat(sampleRate: Int = 48_000): Format = Format.Builder()
.setSampleMimeType(MimeTypes.AUDIO_DTS_HD)
.setChannelCount(6)
.setSampleRate(sampleRate)
.build()
private fun audioSinkConfig(format: Format = trueHdFormat()) = AudioSink.AudioSinkConfig.Builder(format).build()
private fun sink(
@@ -53,7 +70,7 @@ class TrueHdCarrierSinkTest {
normal: FakeSink = FakeSink(),
routeAvailable: Boolean = true,
blocked: Boolean = false
) = TrueHdCarrierSink(normal, carrier, { routeAvailable }, { blocked })
) = IecCarrierSink(normal, carrier, { routeAvailable }, { blocked })
/**
* TrueHD is the carrier or it is decoded. Falling through to the normal sink would hand media3
@@ -75,6 +92,54 @@ class TrueHdCarrierSinkTest {
assertEquals(AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY, carrierSink.getFormatSupport(trueHdFormat()))
}
@Test
fun dtsHdWithACarrierRouteIsSupportedDirectly() {
val carrierSink = sink()
assertTrue(carrierSink.supportsFormat(dtsHdFormat()))
assertEquals(AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY, carrierSink.getFormatSupport(dtsHdFormat()))
}
/**
* The route that advertises the carrier and raw `ENCODING_DTS_HD` at once is the one whose raw
* path renders silence (#1988), so while the carrier route exists DTS-HD is the carrier or it is
* decoded never the normal sink's raw path, even when policy declines the carrier.
*/
@Test
fun dtsHdOnACarrierRouteNeverFallsThroughToTheRawPath() {
val normal = FakeSink().apply { formatSupport = AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY }
val blockedSink = sink(normal = normal, blocked = true)
assertFalse(blockedSink.supportsFormat(dtsHdFormat()))
assertEquals(AudioSink.SINK_FORMAT_UNSUPPORTED, blockedSink.getFormatSupport(dtsHdFormat()))
}
/**
* Routes without the IEC carrier never had it to lose, and some of them bitstream raw DTS-HD
* genuinely; the pre-carrier behavior is preserved there.
*/
@Test
fun dtsHdWithoutACarrierRouteFallsThroughToTheNormalSink() {
val normal = FakeSink().apply { formatSupport = AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY }
val carrierSink = sink(normal = normal, routeAvailable = false)
assertTrue(carrierSink.supportsFormat(dtsHdFormat()))
assertEquals(AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY, carrierSink.getFormatSupport(dtsHdFormat()))
}
/** DTS Express shares the DTS-HD mime prefix but carries `;profile=lbr`; it keeps decoding. */
@Test
fun dtsExpressIsLeftToTheNormalSink() {
val normal = FakeSink().apply { formatSupport = AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY }
val carrierSink = sink(normal = normal)
val express = Format.Builder()
.setSampleMimeType(MimeTypes.AUDIO_DTS_EXPRESS)
.setSampleRate(48_000)
.build()
assertEquals(AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY, carrierSink.getFormatSupport(express))
assertTrue(carrierSink.supportsFormat(express))
}
/** Downmix and normalization already force decoding; the carrier must not override that. */
@Test
fun blockedDirectOutputDeclinesTheCarrier() {
@@ -83,8 +148,8 @@ class TrueHdCarrierSinkTest {
}
/**
* 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
* 44.1kHz-family streams ride a 176.4kHz carrier this path does not build. It has to be decided
* from the format: the packer only learns the rate from the bitstream, long after selection, and
* selecting the carrier for it would produce silence.
*/
@Test
@@ -92,6 +157,8 @@ class TrueHdCarrierSinkTest {
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)))
assertEquals(AudioSink.SINK_FORMAT_UNSUPPORTED, carrierSink.getFormatSupport(dtsHdFormat(sampleRate = 44_100)))
assertEquals(AudioSink.SINK_FORMAT_UNSUPPORTED, carrierSink.getFormatSupport(dtsHdFormat(sampleRate = 88_200)))
}
/** A bitstream cannot be resampled, so any speed other than 1.0x decodes. */
@@ -100,6 +167,7 @@ class TrueHdCarrierSinkTest {
val carrierSink = sink()
carrierSink.setPlaybackParameters(PlaybackParameters(1.5f))
assertEquals(AudioSink.SINK_FORMAT_UNSUPPORTED, carrierSink.getFormatSupport(trueHdFormat()))
assertEquals(AudioSink.SINK_FORMAT_UNSUPPORTED, carrierSink.getFormatSupport(dtsHdFormat()))
}
/**
@@ -183,6 +251,33 @@ class TrueHdCarrierSinkTest {
assertEquals("nothing may reach the carrier delegate", 0, carrier.written.size())
}
/**
* The DTS-HD equivalent: a bitstream whose framing the carrier cannot ride (an S/PDIF-style
* little-endian capture) must latch onto the decoder, and the offending unit must survive.
*/
@Test
fun aDtsHdFramingMismatchLeavesTheCarrierInsteadOfGoingSilent() {
val carrier = FakeSink()
val carrierSink = sink(carrier = carrier)
val listener = RecordingSinkListener()
carrierSink.setListener(listener)
carrierSink.configure(audioSinkConfig(dtsHdFormat()))
val littleEndianCapture = byteArrayOf(0xFE.toByte(), 0x7F, 0x01, 0x80.toByte()) + ByteArray(64)
val buffer = ByteBuffer.wrap(littleEndianCapture)
val accepted = carrierSink.handleBuffer(buffer, 0L, 1)
assertFalse("a mismatch must apply back pressure, not report success", accepted)
assertEquals("the offending unit must stay in the buffer", 0, buffer.position())
assertEquals("the renderer must be asked to reselect", 1, listener.capabilityInvalidations)
assertEquals(
"DTS-HD must now decode rather than ride the carrier or the raw path",
AudioSink.SINK_FORMAT_UNSUPPORTED,
carrierSink.getFormatSupport(dtsHdFormat())
)
assertEquals("nothing may reach the carrier delegate", 0, carrier.written.size())
}
/**
* media3 resets every renderer disabled by a new selection before enabling the replacement, and
* both audio renderers share this sink. That reset lands mid-handover, so a latch cleared there
@@ -278,7 +373,7 @@ class TrueHdCarrierSinkTest {
)
}
/** Everything that is not TrueHD keeps going to the existing processed sink. */
/** Everything that is not a carrier codec keeps going to the existing processed sink. */
@Test
fun otherFormatsAreLeftToTheNormalSink() {
val normal = FakeSink().apply { formatSupport = AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY }
@@ -303,7 +398,7 @@ class TrueHdCarrierSinkTest {
// Refuse every third burst once, so rejections land inside samples rather than between them.
carrier.refuseEveryNth = 3
val produced = feedWholeStream(carrierSink)
val produced = feedWholeStream(carrierSink, accessUnits)
assertArrayEquals("carrier output must survive back pressure unchanged", golden, produced)
assertTrue("the fake must actually have exercised the refusal path", carrier.refusals > 0)
}
@@ -315,7 +410,53 @@ class TrueHdCarrierSinkTest {
val carrierSink = sink(carrier = carrier)
carrierSink.configure(audioSinkConfig())
assertArrayEquals(golden, feedWholeStream(carrierSink))
assertArrayEquals(golden, feedWholeStream(carrierSink, accessUnits))
}
/** A DTS-HD stream must select the DTS packer and come out matching FFmpeg's bytes. */
@Test
fun theDtsHdCarrierOutputMatchesTheGoldenStream() {
val carrier = FakeSink()
val carrierSink = sink(carrier = carrier)
carrierSink.configure(audioSinkConfig(dtsHdFormat()))
assertArrayEquals(dtsHdGolden, feedWholeStream(carrierSink, dtsHdAccessUnits))
}
/** Back pressure must not cost DTS-HD units either; bursts and units are one-to-one there. */
@Test
fun aRefusedDtsHdBurstLosesNoAccessUnits() {
val carrier = FakeSink()
val carrierSink = sink(carrier = carrier)
carrierSink.configure(audioSinkConfig(dtsHdFormat()))
carrier.refuseEveryNth = 3
val produced = feedWholeStream(carrierSink, dtsHdAccessUnits)
assertArrayEquals(dtsHdGolden, produced)
assertTrue(carrier.refusals > 0)
}
/**
* Burst timestamps come from the carrier cadence, not the closing access unit. A DTS-HD burst is
* 10666.67us of carrier not a whole number so the cadence must be derived from cumulative
* carrier frames; per-burst rounding would drift ~62ms over a movie.
*/
@Test
fun dtsHdBurstTimestampsFollowTheExactCarrierCadence() {
val carrier = FakeSink()
val carrierSink = sink(carrier = carrier)
carrierSink.configure(audioSinkConfig(dtsHdFormat()))
feedWholeStream(carrierSink, dtsHdAccessUnits)
val framesPerBurst = 32768L / IecCarrier.BYTES_PER_FRAME
carrier.bufferTimesUs.forEachIndexed { index, timeUs ->
assertEquals(
"burst $index timestamp",
index * framesPerBurst * 1_000_000L / IecCarrier.SAMPLE_RATE,
timeUs
)
}
}
/** A refused burst must be re-offered before any further input is taken. */
@@ -380,8 +521,23 @@ class TrueHdCarrierSinkTest {
assertEquals(inputConfig.mediaPeriodId, configured.mediaPeriodId)
assertEquals(MimeTypes.AUDIO_RAW, configured.format.sampleMimeType)
assertEquals(C.ENCODING_PCM_16BIT, configured.format.pcmEncoding)
assertEquals(TrueHdMatPacker.CARRIER_SAMPLE_RATE, configured.format.sampleRate)
assertEquals(TrueHdMatPacker.CARRIER_CHANNEL_COUNT, configured.format.channelCount)
assertEquals(IecCarrier.SAMPLE_RATE, configured.format.sampleRate)
assertEquals(IecCarrier.CHANNEL_COUNT, configured.format.channelCount)
}
/** DTS-HD rides the same PCM-shaped carrier tuple TrueHD does. */
@Test
fun theDtsHdCarrierDelegateIsConfiguredAsAFixedRatePcmCarrier() {
val carrier = FakeSink()
val carrierSink = sink(carrier = carrier)
carrierSink.configure(audioSinkConfig(dtsHdFormat()))
val configured = checkNotNull(carrier.configuredConfig)
assertEquals(MimeTypes.AUDIO_RAW, configured.format.sampleMimeType)
assertEquals(C.ENCODING_PCM_16BIT, configured.format.pcmEncoding)
assertEquals(IecCarrier.SAMPLE_RATE, configured.format.sampleRate)
assertEquals(IecCarrier.CHANNEL_COUNT, configured.format.channelCount)
}
@Test
@@ -399,18 +555,20 @@ class TrueHdCarrierSinkTest {
assertEquals(null, carrier.configuredConfig)
}
private fun feedWholeStream(carrierSink: TrueHdCarrierSink): ByteArray {
val buffer = ByteBuffer.wrap(accessUnits)
private fun feedWholeStream(carrierSink: IecCarrierSink, units: ByteArray): ByteArray {
val buffer = ByteBuffer.wrap(units)
var guard = 0
while (buffer.hasRemaining() && guard++ < 10_000) {
// media3 retries handleBuffer until it returns true, even once the buffer is fully consumed;
// a refusal of the stream's final burst leaves it pending with nothing remaining to read.
while ((buffer.hasRemaining() || carrierSink.hasPendingData()) && 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")
private fun carrierSinkDelegate(sink: IecCarrierSink): FakeSink {
val field = IecCarrierSink::class.java.getDeclaredField("carrierSink")
field.isAccessible = true
return field.get(sink) as FakeSink
}
@@ -430,6 +588,7 @@ class TrueHdCarrierSinkTest {
/** Minimal AudioSink that records what it was handed and can apply back pressure. */
private class FakeSink : AudioSink {
val written = ByteArrayOutputStream()
val bufferTimesUs = mutableListOf<Long>()
var configuredConfig: AudioSink.AudioSinkConfig? = null
var formatSupport: Int = AudioSink.SINK_FORMAT_UNSUPPORTED
var lastVolume: Float = -1f
@@ -454,6 +613,7 @@ class TrueHdCarrierSinkTest {
val copy = ByteArray(buffer.remaining())
buffer.duplicate().get(copy)
written.write(copy)
bufferTimesUs.add(presentationTimeUs)
buffer.position(buffer.limit())
return true
}
@@ -67,8 +67,8 @@ class TrueHdMatPackerTest {
/** A burst is exactly 20ms of carrier, which is what makes the PCM-domain accounting downstream correct. */
@Test
fun aBurstIsTwentyMillisecondsOfCarrier() {
val framesPerBurst = TrueHdMatPacker.MAT_PKT_OFFSET / TrueHdMatPacker.CARRIER_BYTES_PER_FRAME
val durationUs = framesPerBurst * 1_000_000L / TrueHdMatPacker.CARRIER_SAMPLE_RATE
val framesPerBurst = TrueHdMatPacker.MAT_PKT_OFFSET / IecCarrier.BYTES_PER_FRAME
val durationUs = framesPerBurst * 1_000_000L / IecCarrier.SAMPLE_RATE
assertEquals(20_000L, durationUs)
}
@@ -166,11 +166,11 @@ class TrueHdMatPackerTest {
val length = TrueHdMatPacker.accessUnitLength(units, 0, units.size)
packer.packAccessUnit(units, 0, length)
assertTrue("the fixture must actually announce the 44.1kHz family", packer.unsupportedRateFamily)
assertTrue("the fixture must actually announce the 44.1kHz family", packer.unsupportedStream)
packer.reset()
assertFalse("reset must clear the flag", packer.unsupportedRateFamily)
assertFalse("reset must clear the flag", packer.unsupportedStream)
assertArrayEquals(
"a clean packer must reproduce the stream after a poisoned one",
golden,
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env python3
"""Regenerates the DTS-HD MA packer fixtures (#1988).
FFmpeg has no DTS-HD MA encoder, so the access-unit fixture is synthesized: real
DTS core frames from FFmpeg's `dca` encoder, each followed by a hand-built
extension substream (EXSS) whose header fields are coherent enough for FFmpeg's
DCA parser to glue core+EXSS into one access unit. The IEC 61937 wrapping never
inspects EXSS payload bytes, only the sync word and size fields, so this pins
the same wire format genuine Master Audio does.
One mid-stream access unit is oversized so `ffmpeg -f spdif` overflows the
burst and strips to core-only for `dtshd_fallback_time` (60s, i.e. the rest of
the fixture) — the golden therefore also pins the strip-and-hold behavior.
Usage (from this directory):
ffmpeg -y -f lavfi -i "aevalsrc=0.3*sin(2*PI*440*t)|0.3*sin(2*PI*554*t)|0.3*sin(2*PI*659*t)|0.2*sin(2*PI*220*t)|0.25*sin(2*PI*330*t)|0.25*sin(2*PI*392*t):s=48000:d=0.15" \
-c:a dca -strict experimental -f dts /tmp/dts_core.dts
python3 dtshd_fixture_generator.py /tmp/dts_core.dts
ffmpeg -y -f dts -i dtshd_ma_access_units.bin -c:a copy \
-f spdif -dtshd_rate 768000 dtshd_ma_iec61937_golden.bin
"""
import struct
import sys
CORE_SYNC = 0x7FFE8001
EXSS_SYNC = 0x64582025
# Payload bytes past which a 32768-byte burst (512 samples at 48kHz on the
# 768kHz HBR carrier) overflows: 32768 - 8 preamble - 12 start code/length.
BURST_CAPACITY = 32768 - 8 - 12
NORMAL_EXSS_SIZE = 2020
# Large enough that core (1884) + EXSS exceeds the burst capacity.
OVERSIZED_EXSS_SIZE = BURST_CAPACITY
OVERSIZED_AU_INDEX = 4
def build_exss(size: int) -> bytes:
"""A minimal EXSS frame: sync, then the narrow (bHeaderSizeType=0) header.
Bit layout after the 32-bit sync: UserDefinedBits(8), nExtSSIndex(2),
bHeaderSizeType(1), nuBits4Header(8) = header bytes - 1,
nuBits4ExSSFsize(16) = frame bytes - 1. Everything else is padding the
parser and packer never read.
"""
header_size = 16
bits = 0
bits = (bits << 8) | 0 # UserDefinedBits
bits = (bits << 2) | 0 # nExtSSIndex
bits = (bits << 1) | 0 # bHeaderSizeType
bits = (bits << 8) | (header_size - 1)
bits = (bits << 16) | (size - 1)
packed = bits.to_bytes(5, "big") # 35 bits, left-aligned below
body = bytearray(size)
struct.pack_into(">I", body, 0, EXSS_SYNC)
shifted = int.from_bytes(packed, "big") << (40 - 35)
body[4:9] = shifted.to_bytes(5, "big")
return bytes(body)
def main() -> None:
core = open(sys.argv[1], "rb").read()
out = bytearray()
offset = 0
index = 0
while offset + 9 <= len(core):
sync = struct.unpack_from(">I", core, offset)[0]
assert sync == CORE_SYNC, hex(sync)
b24 = (core[offset + 5] << 16) | (core[offset + 6] << 8) | core[offset + 7]
fsize = ((b24 >> 4) & 0x3FFF) + 1
out += core[offset : offset + fsize]
exss_size = OVERSIZED_EXSS_SIZE if index == OVERSIZED_AU_INDEX else NORMAL_EXSS_SIZE
out += build_exss(exss_size)
offset += fsize
index += 1
open("dtshd_ma_access_units.bin", "wb").write(out)
print(f"{index} access units, {len(out)} bytes")
if __name__ == "__main__":
main()