fix(player): show audio codec and bitrate in the performance overlay on ExoPlayer
The Android performance overlay showed only sample rate and channels for EAC3, FLAC, DTS, TrueHD, and Opus tracks. The overlay reads the codec from media3's Format.codecs, an RFC 6381 string only MP4/HLS provide (hence AAC working), and the bitrate from Format.bitrate, which Matroska carries only when the muxer wrote BPS statistics tags. Fall back to the already-transmitted sample MIME type for the codec name (audio and video), and measure the audio bitrate in the FFmpeg demuxer from packet sizes over their pts span — the same source mpv uses for its audio-bitrate property — when the container declares none. close #2063
This commit is contained in:
@@ -563,6 +563,10 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
|
||||
@Volatile private var activeDoviMp4Wrapper: DoviExtractorWrapper? = null
|
||||
|
||||
// The FFmpeg extractor from the current session; getStats reads its
|
||||
// measured audio bitrate for the playing item.
|
||||
@Volatile private var activeFfmpegExtractor: FfmpegExtractor? = null
|
||||
|
||||
// Container-demuxer placement; read per media item when extractors are built.
|
||||
@Volatile private var demuxerPreference: FfmpegDemuxerPolicy.Preference = FfmpegDemuxerPolicy.Preference.FFMPEG
|
||||
|
||||
@@ -785,7 +789,7 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
currentDvMode,
|
||||
subtitleParserFactory,
|
||||
handler
|
||||
)
|
||||
)?.also { activeFfmpegExtractor = it }
|
||||
)
|
||||
(
|
||||
ffmpegFirst + extractorsFactory.createExtractors().map { extractor ->
|
||||
@@ -3772,6 +3776,7 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
dv7RetryAttempted = override != null
|
||||
activeDoviMkvWrapper = null
|
||||
activeDoviMp4Wrapper = null
|
||||
activeFfmpegExtractor = null
|
||||
val debugMode = override?.name ?: "AUTO"
|
||||
emitLog("info", "dv-debug", "P7 DV conversion mode set to $debugMode (active=$dvMode)")
|
||||
reloadCurrentMediaForDvMode()
|
||||
@@ -3808,6 +3813,7 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
lastDvPlaybackInfo = null
|
||||
activeDoviMkvWrapper = null
|
||||
activeDoviMp4Wrapper = null
|
||||
activeFfmpegExtractor = null
|
||||
stopFrameWatchdog()
|
||||
cancelDecoderHangCheck()
|
||||
cancelResumeStallWatchdog()
|
||||
@@ -4273,7 +4279,12 @@ class ExoPlayerCore(private val activity: Activity) :
|
||||
"audioMimeType" to audioFormat?.sampleMimeType,
|
||||
"audioSampleRate" to audioFormat?.sampleRate,
|
||||
"audioChannels" to audioFormat?.channelCount,
|
||||
"audioBitrate" to audioFormat?.bitrate,
|
||||
// Container-declared bitrate when present; otherwise the FFmpeg
|
||||
// demuxer's measured value (Matroska rarely carries BPS tags — #2063).
|
||||
"audioBitrate" to (
|
||||
audioFormat?.bitrate?.takeIf { it > 0 }
|
||||
?: audioFormat?.id?.let { activeFfmpegExtractor?.measuredAudioBitrateBps(it) }
|
||||
),
|
||||
"audioDecoderName" to audioDecoderInitName,
|
||||
"audioOutputEncoding" to audioTrackConfig?.encoding,
|
||||
"audioOutputChannels" to audioTrackConfig?.channelConfig?.let { Integer.bitCount(it) },
|
||||
|
||||
@@ -118,6 +118,10 @@ internal class FfmpegExtractor private constructor(
|
||||
private var subtitleKinds: Array<SubtitleKind> = emptyArray()
|
||||
private val latmOutputs = ArrayList<LatmTrackOutput>()
|
||||
|
||||
// Per-audio-stream measured bitrate, parallel to trackOutputs. Written by
|
||||
// the loader thread, read by ExoPlayerCore.getStats on another thread.
|
||||
@Volatile private var bitrateMeters: Array<StreamBitrateMeter?> = emptyArray()
|
||||
|
||||
// Seek index for the primary stream: (presentation time, input position) of
|
||||
// video keyframes or periodic audio packets. The SeekMap reads it on the
|
||||
// playback thread while the loader thread appends, hence the lock.
|
||||
@@ -231,6 +235,21 @@ internal class FfmpegExtractor private constructor(
|
||||
opened = false
|
||||
FfmpegDemuxerJni.nativeClose()
|
||||
}
|
||||
// media3's extractors may demux a later item while this instance keeps
|
||||
// the previous one's stream layout; a stale measurement must not leak
|
||||
// into stats.
|
||||
bitrateMeters = emptyArray()
|
||||
}
|
||||
|
||||
/**
|
||||
* Measured average bitrate (bps) of the audio stream whose index matches
|
||||
* [formatId] (this extractor sets `Format.id` to the stream index), or null
|
||||
* when this instance is not demuxing that stream. Fallback for containers
|
||||
* that declare no per-track bitrate (#2063).
|
||||
*/
|
||||
fun measuredAudioBitrateBps(formatId: String?): Int? {
|
||||
val index = formatId?.toIntOrNull() ?: return null
|
||||
return bitrateMeters.getOrNull(index)?.bitrateBps()
|
||||
}
|
||||
|
||||
// The in-call `reconciles` counter resets on every read() invocation, so it
|
||||
@@ -286,6 +305,7 @@ internal class FfmpegExtractor private constructor(
|
||||
durationUs = FfmpegDemuxerJni.nativeDurationUs().takeIf { it >= 0 } ?: C.TIME_UNSET
|
||||
trackOutputs = arrayOfNulls(count)
|
||||
subtitleKinds = Array(count) { SubtitleKind.NONE }
|
||||
bitrateMeters = arrayOfNulls(count)
|
||||
latmOutputs.clear()
|
||||
// Fonts go in before any text track exists so AssHandler's store flushes
|
||||
// them into libass when the first ASS track is created.
|
||||
@@ -360,6 +380,7 @@ internal class FfmpegExtractor private constructor(
|
||||
val pcmEncoding = numbers[FfmpegDemuxerJni.INFO_PCM_ENCODING].toInt()
|
||||
if (pcmEncoding >= 0) builder.setPcmEncoding(pcmEncoding)
|
||||
if (primaryAudio < 0) primaryAudio = index
|
||||
bitrateMeters[index] = StreamBitrateMeter()
|
||||
}
|
||||
C.TRACK_TYPE_TEXT -> {
|
||||
val kind = when (mime) {
|
||||
@@ -485,6 +506,7 @@ internal class FfmpegExtractor private constructor(
|
||||
null
|
||||
)
|
||||
recordSeekPoint(streamIndex, ptsUs, packetOut[FfmpegDemuxerJni.OUT_POSITION], isKeyframe)
|
||||
bitrateMeters.getOrNull(streamIndex)?.onPacket(ptsUs, size)
|
||||
return Extractor.RESULT_CONTINUE
|
||||
}
|
||||
FfmpegDemuxerJni.CODE_EOF -> return Extractor.RESULT_END_OF_INPUT
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
/**
|
||||
* Rolling average bitrate of one demuxed stream, measured from packet sizes
|
||||
* over their presentation-time span — the same source of truth mpv uses for
|
||||
* its `audio-bitrate` property. It exists because containers frequently
|
||||
* declare no per-track bitrate (Matroska without BPS statistics tags), which
|
||||
* left the performance overlay with nothing to show (#2063).
|
||||
*
|
||||
* Fed from the loader thread ([onPacket]) and read from the stats path
|
||||
* ([bitrateBps]); both are synchronized. The measurement follows the demux
|
||||
* position, which runs ahead of the playhead by the buffered duration —
|
||||
* acceptable for a diagnostics overlay.
|
||||
*/
|
||||
internal class StreamBitrateMeter(private val windowUs: Long = DEFAULT_WINDOW_US) {
|
||||
|
||||
private val ptsUsQueue = ArrayDeque<Long>()
|
||||
private val sizeQueue = ArrayDeque<Int>()
|
||||
private var windowBytes = 0L
|
||||
|
||||
@Synchronized
|
||||
fun onPacket(ptsUs: Long, sizeBytes: Int) {
|
||||
if (sizeBytes <= 0) return
|
||||
// A backwards pts jump is a seek; packets on either side of it do not
|
||||
// form a contiguous span, so the window restarts.
|
||||
val last = ptsUsQueue.lastOrNull()
|
||||
if (last != null && ptsUs < last) reset()
|
||||
ptsUsQueue.addLast(ptsUs)
|
||||
sizeQueue.addLast(sizeBytes)
|
||||
windowBytes += sizeBytes
|
||||
while (ptsUsQueue.size > 1 && ptsUsQueue.last() - ptsUsQueue.first() > windowUs) {
|
||||
ptsUsQueue.removeFirst()
|
||||
windowBytes -= sizeQueue.removeFirst()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Average bitrate in bits per second over the current window, or null until
|
||||
* enough packets span a measurable interval.
|
||||
*/
|
||||
@Synchronized
|
||||
fun bitrateBps(): Int? {
|
||||
if (ptsUsQueue.size < MIN_PACKETS) return null
|
||||
val spanUs = ptsUsQueue.last() - ptsUsQueue.first()
|
||||
if (spanUs <= 0) return null
|
||||
// The pts span covers the durations of every packet except the last one,
|
||||
// whose bytes are therefore excluded — a constant-rate stream measures
|
||||
// exactly its rate.
|
||||
val bytes = windowBytes - sizeQueue.last()
|
||||
if (bytes <= 0) return null
|
||||
return (bytes * 8_000_000L / spanUs).toInt()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun reset() {
|
||||
ptsUsQueue.clear()
|
||||
sizeQueue.clear()
|
||||
windowBytes = 0L
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val DEFAULT_WINDOW_US = 10_000_000L
|
||||
private const val MIN_PACKETS = 16
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class StreamBitrateMeterTest {
|
||||
|
||||
/** Feeds [count] packets of [sizeBytes] every [intervalUs], starting at [startUs]. */
|
||||
private fun StreamBitrateMeter.feed(count: Int, sizeBytes: Int, intervalUs: Long, startUs: Long = 0L) {
|
||||
for (i in 0 until count) onPacket(startUs + i * intervalUs, sizeBytes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `constant packet stream measures its bitrate`() {
|
||||
val meter = StreamBitrateMeter()
|
||||
// 1200-byte packets every 32 ms: 1200 * 8 / 0.032 = 300 kbps.
|
||||
meter.feed(count = 32, sizeBytes = 1200, intervalUs = 32_000)
|
||||
assertEquals(300_000, meter.bitrateBps())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `too few packets report nothing`() {
|
||||
val meter = StreamBitrateMeter()
|
||||
meter.feed(count = 15, sizeBytes = 1200, intervalUs = 32_000)
|
||||
assertNull(meter.bitrateBps())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `zero pts span reports nothing`() {
|
||||
val meter = StreamBitrateMeter()
|
||||
repeat(32) { meter.onPacket(0L, 1200) }
|
||||
assertNull(meter.bitrateBps())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `window drops old packets so a bitrate change is followed`() {
|
||||
val meter = StreamBitrateMeter(windowUs = 1_000_000)
|
||||
meter.feed(count = 100, sizeBytes = 1200, intervalUs = 32_000)
|
||||
// Continue at four times the packet size; after well over a window of
|
||||
// new packets, only the new rate remains.
|
||||
meter.feed(count = 100, sizeBytes = 4800, intervalUs = 32_000, startUs = 100 * 32_000L)
|
||||
assertEquals(1_200_000, meter.bitrateBps())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `backwards pts jump resets the window`() {
|
||||
val meter = StreamBitrateMeter()
|
||||
meter.feed(count = 32, sizeBytes = 1200, intervalUs = 32_000)
|
||||
// Seek back: the single post-seek packet must not combine with the
|
||||
// pre-seek span.
|
||||
meter.onPacket(0L, 1200)
|
||||
assertNull(meter.bitrateBps())
|
||||
meter.feed(count = 31, sizeBytes = 1200, intervalUs = 32_000, startUs = 32_000)
|
||||
assertEquals(300_000, meter.bitrateBps())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty packets are ignored`() {
|
||||
val meter = StreamBitrateMeter()
|
||||
meter.feed(count = 32, sizeBytes = 1200, intervalUs = 32_000)
|
||||
meter.onPacket(32 * 32_000L, 0)
|
||||
assertEquals(300_000, meter.bitrateBps())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user