fix(exoplayer): unwrap AAC-LATM (LOAS) audio in MKV direct streams
close #1521
This commit is contained in:
@@ -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.
Reference in New Issue
Block a user