fix(exoplayer): unwrap AAC-LATM (LOAS) audio in MKV direct streams

close #1521
This commit is contained in:
edde746
2026-07-10 23:23:44 +02:00
parent 9b6c12d086
commit 03651d6c73
7 changed files with 547 additions and 17 deletions
+3
View File
@@ -210,4 +210,7 @@ dependencies {
implementation(project(":libass"))
testImplementation("junit:junit:4.13.2")
// Real android.util.* implementations for tests exercising media3 classes
// (MatroskaExtractor uses SparseArray, which is a no-op stub on plain JVM)
testImplementation("org.robolectric:robolectric:4.15.1")
}
@@ -1314,7 +1314,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
if (currentMediaIsLive) {
val factory = dataSourceFactory ?: return false
val extractorsFactory = androidx.media3.extractor.ExtractorsFactory {
arrayOf(MatroskaExtractor(MatroskaExtractor.FLAG_DISABLE_SEEK_FOR_CUES))
arrayOf(LatmMatroskaExtractor(MatroskaExtractor.FLAG_DISABLE_SEEK_FOR_CUES))
}
val mediaSource = ProgressiveMediaSource.Factory(factory, extractorsFactory)
.createMediaSource(MediaItem.fromUri(uri))
@@ -2968,7 +2968,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
// so data flows immediately without hanging.
// Headers already applied to httpDataSourceFactory above.
val extractorsFactory = androidx.media3.extractor.ExtractorsFactory {
arrayOf(MatroskaExtractor(MatroskaExtractor.FLAG_DISABLE_SEEK_FOR_CUES))
arrayOf(LatmMatroskaExtractor(MatroskaExtractor.FLAG_DISABLE_SEEK_FOR_CUES))
}
val mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory!!, extractorsFactory)
@@ -0,0 +1,112 @@
package com.edde746.plezy.exoplayer
import android.util.Log
import androidx.media3.extractor.ExtractorOutput
import androidx.media3.extractor.SeekMap
import androidx.media3.extractor.TrackOutput
import androidx.media3.extractor.mkv.MatroskaExtractor
/** MKV CodecID for Microsoft ACM compatibility mode. */
private const val CODEC_ID_ACM = "A_MS/ACM"
/**
* MatroskaExtractor.init is final and extractorOutput private; subclasses swap
* in wrapping outputs via reflection once the Segment element starts (shared
* with ZlibMatroskaExtractor).
*/
internal val matroskaExtractorOutputField by lazy {
MatroskaExtractor::class.java.getDeclaredField("extractorOutput").apply {
isAccessible = true
}
}
/** WAVEFORMATEX format tag for LOAS/LATM-wrapped AAC (WAVE_FORMAT_MPEG_LOAS). */
private const val WAVE_FORMAT_MPEG_LOAS = 0x1602
/**
* Returns whether a track is LOAS/LATM AAC muxed as A_MS/ACM — ffmpeg's (and
* therefore Plex's) fallback mapping for aac_latm, which Matroska has no native
* codec ID for. The WAVEFORMATEX wFormatTag is the first 2 bytes (LE) of
* CodecPrivate.
*/
fun isLoasAcmTrack(codecId: String?, codecPrivate: ByteArray?): Boolean = codecId == CODEC_ID_ACM &&
codecPrivate != null &&
codecPrivate.size >= 2 &&
((codecPrivate[0].toInt() and 0xFF) or ((codecPrivate[1].toInt() and 0xFF) shl 8)) == WAVE_FORMAT_MPEG_LOAS
/**
* ExtractorOutput wrapper that wraps marked tracks with [LatmTrackOutput].
* Call [markNextTrackLatm] before the parent extractor creates the track
* (i.e. before super.endMasterElement(ID_TRACK_ENTRY)).
*/
class LatmExtractorOutputWrapper(
private val delegate: ExtractorOutput
) : ExtractorOutput {
private var nextTrackIsLatm = false
private val latmOutputs = mutableListOf<LatmTrackOutput>()
fun markNextTrackLatm() {
nextTrackIsLatm = true
}
/** Resets LATM parser state after an extractor seek. */
fun resetTracks() {
latmOutputs.forEach { it.reset() }
}
override fun track(id: Int, type: Int): TrackOutput {
val original = delegate.track(id, type)
if (!nextTrackIsLatm) return original
nextTrackIsLatm = false
return LatmTrackOutput(original, id).also { latmOutputs.add(it) }
}
override fun endTracks() = delegate.endTracks()
override fun seekMap(seekMap: SeekMap) = delegate.seekMap(seekMap)
}
/**
* MatroskaExtractor with LOAS/LATM AAC support, used for Plex Live TV MKV
* streams (which bypass the ASS/DV extractor chain). VOD playback gets the
* same LATM handling via ZlibMatroskaExtractor.
*/
class LatmMatroskaExtractor(flags: Int) : MatroskaExtractor(flags) {
companion object {
private const val TAG = "LatmMkvExtractor"
private const val ID_SEGMENT = 0x18538067
private const val ID_TRACK_ENTRY = 0xAE
}
private var latmWrapper: LatmExtractorOutputWrapper? = null
override fun startMasterElement(id: Int, contentPosition: Long, contentSize: Long) {
super.startMasterElement(id, contentPosition, contentSize)
// init() is final, so install the wrapping output when the Segment starts —
// before any TrackEntry can create a track through it.
if (id == ID_SEGMENT && latmWrapper == null) {
val currentOutput = matroskaExtractorOutputField.get(this) as ExtractorOutput
val wrapper = LatmExtractorOutputWrapper(currentOutput)
latmWrapper = wrapper
matroskaExtractorOutputField.set(this, wrapper)
}
}
override fun endMasterElement(id: Int) {
if (id == ID_TRACK_ENTRY) {
val track = getCurrentTrack(id)
if (isLoasAcmTrack(track.codecId, track.codecPrivate)) {
Log.i(TAG, "Track ${track.number} is LOAS/LATM AAC, unwrapping to raw AAC")
latmWrapper?.markNextTrackLatm()
}
}
super.endMasterElement(id)
}
override fun seek(position: Long, timeUs: Long) {
latmWrapper?.resetTracks()
super.seek(position, timeUs)
}
}
@@ -0,0 +1,156 @@
package com.edde746.plezy.exoplayer
import android.util.Log
import androidx.media3.common.C
import androidx.media3.common.DataReader
import androidx.media3.common.Format
import androidx.media3.common.MimeTypes
import androidx.media3.common.util.ParsableByteArray
import androidx.media3.extractor.ExtractorOutput
import androidx.media3.extractor.SeekMap
import androidx.media3.extractor.TrackOutput
import androidx.media3.extractor.ts.LatmReader
import androidx.media3.extractor.ts.TsPayloadReader
import java.io.EOFException
/**
* TrackOutput wrapper that unwraps LOAS/LATM-framed AAC (MKV A_MS/ACM with
* WAVEFORMATEX tag 0x1602) into raw AAC access units MediaCodec can decode.
*
* Each MKV block payload is one or more complete LOAS AudioSyncStream frames.
* Blocks are buffered between sampleData() and sampleMetadata(), then fed to
* media3's LatmReader, which parses the StreamMuxConfig (emitting a proper AAC
* Format with AudioSpecificConfig) and outputs byte-aligned raw AAC samples.
*
* The parent extractor's audio/x-unknown Format is swallowed; its track-selection
* metadata (id, label, language, selection flags) is merged onto the Format
* LatmReader derives from the stream.
*/
class LatmTrackOutput(
private val delegate: TrackOutput,
private val trackId: Int
) : TrackOutput {
companion object {
private const val TAG = "LatmTrackOutput"
private const val INITIAL_BUFFER_SIZE = 4 * 1024
private const val MAX_LOGGED_ERRORS = 3
}
private var latmReader: LatmReader? = null
private var originalFormat: Format? = null
private val parsable = ParsableByteArray()
// Reusable block buffer — grown as needed, never shrunk
private var buf = ByteArray(INITIAL_BUFFER_SIZE)
private var bufLen = 0
private var readBuf = ByteArray(INITIAL_BUFFER_SIZE)
private var errorCount = 0
/** Forwards LatmReader's decoded Format merged with the original track metadata. */
private val mergeProxy = object : TrackOutput {
override fun format(format: Format) {
val original = originalFormat
val merged = if (original == null) {
format
} else {
format.buildUpon()
.setId(original.id)
.setLabel(original.label)
.setLanguage(original.language ?: format.language)
.setSelectionFlags(original.selectionFlags)
.build()
}
delegate.format(merged)
}
override fun sampleData(input: DataReader, length: Int, allowEndOfInput: Boolean, sampleDataPart: Int): Int = delegate.sampleData(input, length, allowEndOfInput, sampleDataPart)
override fun sampleData(data: ParsableByteArray, length: Int, sampleDataPart: Int) = delegate.sampleData(data, length, sampleDataPart)
override fun sampleMetadata(timeUs: Long, flags: Int, size: Int, offset: Int, cryptoData: TrackOutput.CryptoData?) = delegate.sampleMetadata(timeUs, flags, size, offset, cryptoData)
}
private val readerExtractorOutput = object : ExtractorOutput {
override fun track(id: Int, type: Int): TrackOutput = mergeProxy
override fun endTracks() {}
override fun seekMap(seekMap: SeekMap) {}
}
override fun format(format: Format) {
originalFormat = format
if (latmReader == null) {
latmReader = LatmReader(format.language, format.roleFlags, MimeTypes.VIDEO_MATROSKA).also {
it.createTracks(readerExtractorOutput, TsPayloadReader.TrackIdGenerator(trackId, 1))
}
}
// Swallow the parent's audio/x-unknown Format; LatmReader emits the real
// AAC Format (with AudioSpecificConfig) from the first StreamMuxConfig.
}
override fun sampleData(
input: DataReader,
length: Int,
allowEndOfInput: Boolean,
sampleDataPart: Int
): Int {
if (readBuf.size < length) readBuf = ByteArray(length)
val bytesRead = input.read(readBuf, 0, length)
if (bytesRead == C.RESULT_END_OF_INPUT && !allowEndOfInput) throw EOFException()
if (bytesRead > 0) append(readBuf, bytesRead)
return bytesRead
}
override fun sampleData(data: ParsableByteArray, length: Int, sampleDataPart: Int) {
ensureCapacity(bufLen + length)
data.readBytes(buf, bufLen, length)
bufLen += length
}
override fun sampleMetadata(
timeUs: Long,
flags: Int,
size: Int,
offset: Int,
cryptoData: TrackOutput.CryptoData?
) {
// offset counts down to 0 across a laced BlockGroup; the buffer holds the
// whole block's data, so slice this sample out and clear at the last one.
val start = bufLen - offset - size
val reader = latmReader
if (reader == null || start < 0) {
if (errorCount++ < MAX_LOGGED_ERRORS) {
Log.e(TAG, "Dropping sample (reader=${reader != null}, start=$start, size=$size, offset=$offset)")
}
if (offset == 0) bufLen = 0
return
}
try {
reader.packetStarted(timeUs, 0)
parsable.reset(buf, start + size)
parsable.position = start
reader.consume(parsable)
} catch (e: Exception) {
if (errorCount++ < MAX_LOGGED_ERRORS) {
Log.e(TAG, "LATM parse failed (${size}B), dropping sample", e)
}
reader.seek() // resync on the next LOAS syncword
}
if (offset == 0) bufLen = 0
}
/** Drops buffered MKV data while retaining the LATM StreamMuxConfig. */
fun reset() {
bufLen = 0
}
private fun append(src: ByteArray, length: Int) {
ensureCapacity(bufLen + length)
System.arraycopy(src, 0, buf, bufLen, length)
bufLen += length
}
private fun ensureCapacity(needed: Int) {
if (buf.size < needed) buf = buf.copyOf(maxOf(needed, buf.size * 2))
}
}
@@ -5,19 +5,24 @@ import androidx.media3.extractor.ExtractorInput
import androidx.media3.extractor.ExtractorOutput
import androidx.media3.extractor.SeekMap
import androidx.media3.extractor.TrackOutput
import androidx.media3.extractor.mkv.MatroskaExtractor
import androidx.media3.extractor.text.SubtitleParser
import com.edde746.plezy.libass.media.AssHandler
import com.edde746.plezy.libass.media.extractor.AssMatroskaExtractor
/**
* Extends AssMatroskaExtractor to add support for MKV ContentCompAlgo 0 (zlib).
* Extends AssMatroskaExtractor to add support for MKV quirks media3 rejects:
*
* Media3's MatroskaExtractor only supports ContentCompAlgo 3 (header stripping).
* This subclass intercepts the compression algorithm during track header parsing:
* ContentCompAlgo 0 (zlib) — media3 only supports ContentCompAlgo 3 (header
* stripping). This subclass intercepts the compression algorithm during track
* header parsing:
* - Tells the parent it's header stripping (algo 3) to avoid the ParserException
* - Wraps TrackOutputs with ZlibInflatingTrackOutput to decompress per-sample data
* - Skips ContentCompSettings for zlib tracks (not applicable)
*
* LOAS/LATM AAC as A_MS/ACM — media3 sets audio/x-unknown for non-PCM ACM
* tracks (silent playback). Detected tracks are wrapped with LatmTrackOutput,
* which unwraps LOAS frames to raw AAC (see LatmMatroskaExtractor for the
* Live TV counterpart).
*/
class ZlibMatroskaExtractor(
subtitleParserFactory: SubtitleParser.Factory,
@@ -32,27 +37,25 @@ class ZlibMatroskaExtractor(
private const val ID_TRACK_ENTRY = 0xAE
private const val ID_CONTENT_COMPRESSION_ALGORITHM = 0x4254
private const val ID_CONTENT_COMPRESSION_SETTINGS = 0x4255
private val extractorOutputField by lazy {
MatroskaExtractor::class.java.getDeclaredField("extractorOutput").apply {
isAccessible = true
}
}
}
private var zlibOutput: ZlibExtractorOutputWrapper? = null
private var latmOutput: LatmExtractorOutputWrapper? = null
private var currentTrackUsesZlib = false
override fun startMasterElement(id: Int, contentPosition: Long, contentSize: Long) {
super.startMasterElement(id, contentPosition, contentSize)
// After super installs AssSubtitleExtractorOutput, wrap it with our zlib layer
// After super installs AssSubtitleExtractorOutput, wrap it with our zlib +
// LATM layers (zlib outermost so inflation runs before LATM parsing).
if (id == ID_SEGMENT && zlibOutput == null) {
val currentOutput = extractorOutputField.get(this) as ExtractorOutput
val wrapper = ZlibExtractorOutputWrapper(currentOutput)
val currentOutput = matroskaExtractorOutputField.get(this) as ExtractorOutput
val latmWrapper = LatmExtractorOutputWrapper(currentOutput)
latmOutput = latmWrapper
val wrapper = ZlibExtractorOutputWrapper(latmWrapper)
zlibOutput = wrapper
extractorOutputField.set(this, wrapper)
Log.d(TAG, "Installed zlib ExtractorOutput wrapper")
matroskaExtractorOutputField.set(this, wrapper)
Log.d(TAG, "Installed zlib+LATM ExtractorOutput wrapper")
}
}
@@ -78,6 +81,16 @@ class ZlibMatroskaExtractor(
}
override fun endMasterElement(id: Int) {
if (id == ID_TRACK_ENTRY) {
// Must mark before super — the track output is created inside super's
// endMasterElement, and the x-unknown format must never reach the queue.
val track = getCurrentTrack(id)
if (isLoasAcmTrack(track.codecId, track.codecPrivate)) {
Log.i(TAG, "Track ${track.number} is LOAS/LATM AAC, unwrapping to raw AAC")
latmOutput?.markNextTrackLatm()
}
}
val wasZlib = currentTrackUsesZlib
super.endMasterElement(id)
@@ -88,6 +101,11 @@ class ZlibMatroskaExtractor(
}
}
override fun seek(position: Long, timeUs: Long) {
latmOutput?.resetTracks()
super.seek(position, timeUs)
}
/**
* ExtractorOutput wrapper that wraps all TrackOutputs with ZlibInflatingTrackOutput.
* Tracks are created inactive; activateLast() enables inflation for the most recently
@@ -0,0 +1,241 @@
package com.edde746.plezy.exoplayer
import androidx.media3.common.C
import androidx.media3.common.DataReader
import androidx.media3.common.Format
import androidx.media3.common.MimeTypes
import androidx.media3.common.util.ParsableByteArray
import androidx.media3.extractor.DefaultExtractorInput
import androidx.media3.extractor.Extractor
import androidx.media3.extractor.ExtractorOutput
import androidx.media3.extractor.PositionHolder
import androidx.media3.extractor.SeekMap
import androidx.media3.extractor.TrackOutput
import androidx.media3.extractor.mkv.MatroskaExtractor
import java.io.EOFException
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/**
* Extracts the committed fixture (1s 440Hz sine, AAC-LC 48kHz stereo, LATM/LOAS
* muxed into MKV as A_MS/ACM tag 0x1602 — the layout Plex produces when
* Direct-Streaming HDHomeRun aac_latm audio) and verifies LOAS frames are
* unwrapped to raw AAC with a synthesized AudioSpecificConfig.
*
* Robolectric provides real android.util.* implementations — MatroskaExtractor
* stores tracks in a SparseArray, which is a no-op stub on plain JVM.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class LatmMatroskaExtractorTest {
private class CapturedSample(val timeUs: Long, val flags: Int, val data: ByteArray)
private class FakeTrackOutput : TrackOutput {
val formats = mutableListOf<Format>()
val samples = mutableListOf<CapturedSample>()
private var buf = ByteArray(64 * 1024)
private var bufLen = 0
override fun format(format: Format) {
formats.add(format)
}
override fun sampleData(input: DataReader, length: Int, allowEndOfInput: Boolean, sampleDataPart: Int): Int {
ensureCapacity(bufLen + length)
val read = input.read(buf, bufLen, length)
if (read > 0) bufLen += read
return read
}
override fun sampleData(data: ParsableByteArray, length: Int, sampleDataPart: Int) {
ensureCapacity(bufLen + length)
data.readBytes(buf, bufLen, length)
bufLen += length
}
override fun sampleMetadata(timeUs: Long, flags: Int, size: Int, offset: Int, cryptoData: TrackOutput.CryptoData?) {
val start = bufLen - offset - size
samples.add(CapturedSample(timeUs, flags, buf.copyOfRange(start, start + size)))
if (offset == 0) bufLen = 0
}
private fun ensureCapacity(needed: Int) {
if (buf.size < needed) buf = buf.copyOf(maxOf(needed, buf.size * 2))
}
}
private class FakeExtractorOutput : ExtractorOutput {
val tracks = mutableMapOf<Int, FakeTrackOutput>()
override fun track(id: Int, type: Int): TrackOutput = tracks.getOrPut(id) { FakeTrackOutput() }
override fun endTracks() {}
override fun seekMap(seekMap: SeekMap) {}
}
private class ByteArrayDataReader(private val data: ByteArray) : DataReader {
var position = 0L
override fun read(buffer: ByteArray, offset: Int, length: Int): Int {
if (position >= data.size) return C.RESULT_END_OF_INPUT
val toRead = minOf(length, data.size - position.toInt())
System.arraycopy(data, position.toInt(), buffer, offset, toRead)
position += toRead
return toRead
}
}
private fun fixtureData(): ByteArray = checkNotNull(javaClass.getResourceAsStream("/latm_loas.mkv")) {
"fixture latm_loas.mkv missing from test resources"
}.use { it.readBytes() }
private fun loasFrames(count: Int): List<ByteArray> {
val data = fixtureData()
val frames = mutableListOf<ByteArray>()
var position = 0
while (position <= data.size - 3 && frames.size < count) {
val isSyncWord = (data[position].toInt() and 0xFF) == 0x56 &&
(data[position + 1].toInt() and 0xE0) == 0xE0
if (isSyncWord) {
val payloadSize = ((data[position + 1].toInt() and 0x1F) shl 8) or
(data[position + 2].toInt() and 0xFF)
val end = position + 3 + payloadSize
if (payloadSize > 0 && end <= data.size) {
frames.add(data.copyOfRange(position, end))
position = end
continue
}
}
position++
}
check(frames.size == count) { "expected $count LOAS frames, found ${frames.size}" }
return frames
}
private fun extractFixture(): FakeExtractorOutput {
val data = fixtureData()
val extractor = LatmMatroskaExtractor(MatroskaExtractor.FLAG_DISABLE_SEEK_FOR_CUES)
val output = FakeExtractorOutput()
extractor.init(output)
val reader = ByteArrayDataReader(data)
var input = DefaultExtractorInput(reader, 0, data.size.toLong())
val seekPosition = PositionHolder()
while (true) {
when (extractor.read(input, seekPosition)) {
Extractor.RESULT_END_OF_INPUT -> return output
Extractor.RESULT_SEEK -> {
reader.position = seekPosition.position
input = DefaultExtractorInput(reader, seekPosition.position, data.size.toLong())
}
else -> {}
}
}
}
@Test
fun unwrapsLoasAcmTrackToRawAac() {
val output = extractFixture()
assertEquals(1, output.tracks.size)
val track = output.tracks.values.first()
// No audio/x-unknown format may reach the queue; the LATM-derived AAC
// format must carry the AudioSpecificConfig for MediaCodec.
assertFalse(track.formats.any { it.sampleMimeType == MimeTypes.AUDIO_UNKNOWN })
val format = track.formats.last()
assertEquals(MimeTypes.AUDIO_AAC, format.sampleMimeType)
assertEquals(48000, format.sampleRate)
assertEquals(2, format.channelCount)
assertTrue(format.initializationData.isNotEmpty())
assertTrue(format.initializationData[0].isNotEmpty())
// 1s at 48kHz / 1024 samples per AAC frame ≈ 47 frames
assertTrue("expected ~47 samples, got ${track.samples.size}", track.samples.size in 40..55)
// Raw AAC payloads: smaller than the LOAS wrapping, no LOAS syncword,
// keyframe-flagged, monotonic timestamps spanning ~1s.
var prevTimeUs = Long.MIN_VALUE
for (sample in track.samples) {
assertTrue(sample.data.isNotEmpty())
val isLoasSync = sample.data.size >= 2 &&
(sample.data[0].toInt() and 0xFF) == 0x56 &&
(sample.data[1].toInt() and 0xE0) == 0xE0
assertFalse("sample still LOAS-framed", isLoasSync)
assertEquals(C.BUFFER_FLAG_KEY_FRAME, sample.flags and C.BUFFER_FLAG_KEY_FRAME)
assertTrue(sample.timeUs >= prevTimeUs)
prevTimeUs = sample.timeUs
}
val spanUs = track.samples.last().timeUs - track.samples.first().timeUs
assertTrue("expected ~1s span, got ${spanUs}us", spanUs in 800_000..1_200_000)
}
@Test
fun detectsLoasAcmCodecPrivate() {
val loas = byteArrayOf(0x02, 0x16, 0, 0, 0, 0)
assertTrue(isLoasAcmTrack("A_MS/ACM", loas))
// Wrong tag (PCM), wrong codec, or missing private data must not match
assertFalse(isLoasAcmTrack("A_MS/ACM", byteArrayOf(0x01, 0x00, 0, 0)))
assertFalse(isLoasAcmTrack("A_AAC", loas))
assertFalse(isLoasAcmTrack("A_MS/ACM", null))
assertFalse(isLoasAcmTrack("A_MS/ACM", byteArrayOf(0x02)))
assertFalse(isLoasAcmTrack(null, loas))
}
@Test
fun formatIsEmittedBeforeFirstSample() {
val output = extractFixture()
val track = output.tracks.values.first()
assertNotNull(track.formats.firstOrNull())
// LatmReader emits the format from the first StreamMuxConfig, which arrives
// with the first LOAS frame — before any sample metadata is committed.
assertTrue(track.samples.isNotEmpty())
}
@Test
fun rejectsUnexpectedEndOfInput() {
val output = LatmTrackOutput(FakeTrackOutput(), 1)
val reader = ByteArrayDataReader(ByteArray(0))
assertThrows(EOFException::class.java) {
output.sampleData(reader, 1, false, TrackOutput.SAMPLE_DATA_PART_MAIN)
}
assertEquals(
C.RESULT_END_OF_INPUT,
output.sampleData(reader, 1, true, TrackOutput.SAMPLE_DATA_PART_MAIN)
)
}
@Test
fun preservesStreamMuxConfigAcrossExtractorSeek() {
val frames = loasFrames(2)
assertEquals(0, frames[0][3].toInt() and 0x80)
assertEquals(0x80, frames[1][3].toInt() and 0x80)
val delegate = FakeTrackOutput()
val output = LatmTrackOutput(delegate, 1)
output.format(
Format.Builder()
.setId("1")
.setSampleMimeType(MimeTypes.AUDIO_UNKNOWN)
.build()
)
output.sampleData(ParsableByteArray(frames[0]), frames[0].size, TrackOutput.SAMPLE_DATA_PART_MAIN)
output.sampleMetadata(0, C.BUFFER_FLAG_KEY_FRAME, frames[0].size, 0, null)
assertEquals(1, delegate.samples.size)
output.reset()
output.sampleData(ParsableByteArray(frames[1]), frames[1].size, TrackOutput.SAMPLE_DATA_PART_MAIN)
output.sampleMetadata(21_000, C.BUFFER_FLAG_KEY_FRAME, frames[1].size, 0, null)
assertEquals(2, delegate.samples.size)
}
}
Binary file not shown.