diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 7a62f884..18a23892 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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") } diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt index e020de07..a8029116 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt @@ -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) diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/LatmMatroskaExtractor.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/LatmMatroskaExtractor.kt new file mode 100644 index 00000000..1de2a8a0 --- /dev/null +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/LatmMatroskaExtractor.kt @@ -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() + + 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) + } +} diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/LatmTrackOutput.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/LatmTrackOutput.kt new file mode 100644 index 00000000..f14e7254 --- /dev/null +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/LatmTrackOutput.kt @@ -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)) + } +} diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ZlibMatroskaExtractor.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ZlibMatroskaExtractor.kt index 696640e3..670202ef 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ZlibMatroskaExtractor.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ZlibMatroskaExtractor.kt @@ -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 diff --git a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/LatmMatroskaExtractorTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/LatmMatroskaExtractorTest.kt new file mode 100644 index 00000000..440595df --- /dev/null +++ b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/LatmMatroskaExtractorTest.kt @@ -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() + val samples = mutableListOf() + 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() + + 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 { + val data = fixtureData() + val frames = mutableListOf() + 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) + } +} diff --git a/android/app/src/test/resources/latm_loas.mkv b/android/app/src/test/resources/latm_loas.mkv new file mode 100644 index 00000000..908c86a0 Binary files /dev/null and b/android/app/src/test/resources/latm_loas.mkv differ