From 03651d6c73d39e034c4dcd66608f5462c8d392a9 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 10 Jul 2026 22:33:25 +0200 Subject: [PATCH] fix(exoplayer): unwrap AAC-LATM (LOAS) audio in MKV direct streams close #1521 --- android/app/build.gradle.kts | 3 + .../edde746/plezy/exoplayer/ExoPlayerCore.kt | 4 +- .../plezy/exoplayer/LatmMatroskaExtractor.kt | 112 ++++++++ .../plezy/exoplayer/LatmTrackOutput.kt | 156 ++++++++++++ .../plezy/exoplayer/ZlibMatroskaExtractor.kt | 48 ++-- .../exoplayer/LatmMatroskaExtractorTest.kt | 241 ++++++++++++++++++ android/app/src/test/resources/latm_loas.mkv | Bin 0 -> 9110 bytes 7 files changed, 547 insertions(+), 17 deletions(-) create mode 100644 android/app/src/main/kotlin/com/edde746/plezy/exoplayer/LatmMatroskaExtractor.kt create mode 100644 android/app/src/main/kotlin/com/edde746/plezy/exoplayer/LatmTrackOutput.kt create mode 100644 android/app/src/test/kotlin/com/edde746/plezy/exoplayer/LatmMatroskaExtractorTest.kt create mode 100644 android/app/src/test/resources/latm_loas.mkv 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 0000000000000000000000000000000000000000..908c86a08c4fb1f3eb461800ab65de66daa582d1 GIT binary patch literal 9110 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 t^@+LzTdXZNx1Rfj{r!1)zG(6kAh{a-%vSnL1* literal 0 HcmV?d00001