fix(android): gate TrueHD passthrough
This commit is contained in:
@@ -6,7 +6,12 @@ import android.content.Context
|
||||
import android.graphics.Color
|
||||
import android.graphics.PixelFormat
|
||||
import android.graphics.Typeface
|
||||
import android.media.AudioDeviceInfo
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioManager
|
||||
import android.media.AudioTrack
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
@@ -19,6 +24,7 @@ import android.view.ViewTreeObserver
|
||||
import android.widget.FrameLayout
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.Format
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MimeTypes
|
||||
import androidx.media3.common.PlaybackException
|
||||
@@ -30,14 +36,18 @@ import androidx.media3.common.VideoSize
|
||||
import androidx.media3.common.text.Cue
|
||||
import androidx.media3.common.text.CueGroup
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.common.util.Util
|
||||
import androidx.media3.datasource.DefaultDataSource
|
||||
import androidx.media3.datasource.HttpDataSource
|
||||
import androidx.media3.datasource.cronet.CronetDataSource
|
||||
import androidx.media3.exoplayer.DecoderReuseEvaluation
|
||||
import androidx.media3.exoplayer.DefaultLoadControl
|
||||
import androidx.media3.exoplayer.DefaultRenderersFactory
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.RenderersFactory
|
||||
import androidx.media3.exoplayer.analytics.AnalyticsListener
|
||||
import androidx.media3.exoplayer.audio.AudioCapabilities
|
||||
import androidx.media3.exoplayer.audio.AudioSink
|
||||
import androidx.media3.exoplayer.mediacodec.MediaCodecSelector
|
||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||
import androidx.media3.exoplayer.source.ProgressiveMediaSource
|
||||
@@ -138,6 +148,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
||||
private var decoderInitName: String? = null
|
||||
private var audioDecoderInitName: String? = null
|
||||
private var loggedEwasteEac3Workaround: Boolean = false
|
||||
private var lastTrueHdDirectOutputLogKey: String? = null
|
||||
private var firstFrameRendered: Boolean = false
|
||||
var delegate: ExoPlayerDelegate? = null
|
||||
var debugLoggingEnabled: Boolean = false
|
||||
@@ -385,13 +396,13 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
||||
}
|
||||
|
||||
// Create ExoPlayer with FFmpeg audio decoder fallback
|
||||
val audioAttributes = androidx.media3.common.AudioAttributes.Builder()
|
||||
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
|
||||
.setUsage(C.USAGE_MEDIA)
|
||||
.build()
|
||||
val audioAttributes = buildMovieAudioAttributes()
|
||||
|
||||
// Use DefaultRenderersFactory with FFmpeg fallback for unsupported or blocked audio codecs.
|
||||
val renderersFactory = PlezyRenderersFactory(activity).apply {
|
||||
audioDiagnosticsLogger = { level, prefix, message -> emitLog(level, prefix, message) }
|
||||
shouldBlockDirectTrueHd = { format -> this@ExoPlayerCore.shouldBlockDirectTrueHd(format, "sink support") }
|
||||
onAudioCapabilitiesChanged = { updateAudioDecoderPolicy("audio capabilities changed") }
|
||||
setEnableDecoderFallback(true)
|
||||
setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON)
|
||||
setMediaCodecSelector { mimeType, requiresSecureDecoder, requiresTunnelingDecoder ->
|
||||
@@ -407,6 +418,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
||||
}
|
||||
}
|
||||
this.renderersFactory = renderersFactory
|
||||
updateAudioDecoderPolicy("initialize")
|
||||
|
||||
// Cronet DataSource for HTTP/2 multiplexing — all range requests share one connection
|
||||
httpDataSourceFactory = CronetDataSource.Factory(getCronetEngine(activity), cronetExecutor)
|
||||
@@ -788,7 +800,10 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
||||
}
|
||||
if (audioGroup != null) {
|
||||
val af = audioGroup.mediaTrackGroup.getFormat(0)
|
||||
emitLog("info", "tracks", "Audio: ${af.codecs} ${af.channelCount}ch ${af.sampleRate}Hz")
|
||||
emitLog("info", "tracks", "Audio: ${formatAudioSummary(af)}")
|
||||
if (af.sampleMimeType == MimeTypes.AUDIO_TRUEHD) {
|
||||
updateAudioDecoderPolicy("tracks changed", af)
|
||||
}
|
||||
}
|
||||
// Detect video track present but deselected (unsupported codec — plays audio only)
|
||||
val hasAnyVideoGroup = tracks.groups.any { it.type == C.TRACK_TYPE_VIDEO }
|
||||
@@ -1088,6 +1103,25 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
||||
|
||||
// Tunneling control — disabled when audio codec has no hardware decoder (requires FFmpeg)
|
||||
|
||||
private data class TrueHdDirectOutputDecision(
|
||||
val forceFfmpeg: Boolean,
|
||||
val platformProbe: String,
|
||||
val media3Probe: String,
|
||||
val routeSummary: String
|
||||
)
|
||||
|
||||
private fun shouldBlockDirectTrueHd(format: Format, reason: String): Boolean {
|
||||
val decision = evaluateTrueHdDirectOutput(format)
|
||||
logTrueHdDirectOutputDecision(reason, format, decision)
|
||||
return decision.forceFfmpeg
|
||||
}
|
||||
|
||||
private fun updateAudioDecoderPolicy(reason: String, format: Format? = null) {
|
||||
if (format != null && format.sampleMimeType != MimeTypes.AUDIO_TRUEHD) return
|
||||
val decision = evaluateTrueHdDirectOutput(format)
|
||||
logTrueHdDirectOutputDecision(reason, format, decision)
|
||||
}
|
||||
|
||||
private fun shouldForceAppAudioDecoder(mimeType: String): Boolean {
|
||||
if (mimeType == MimeTypes.AUDIO_FLAC) return true
|
||||
if (DeviceQuirks.isEWaste && isEac3MimeType(mimeType)) {
|
||||
@@ -1100,6 +1134,184 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
||||
return false
|
||||
}
|
||||
|
||||
private fun evaluateTrueHdDirectOutput(format: Format?): TrueHdDirectOutputDecision {
|
||||
val probeFormat = trueHdProbeFormat(format)
|
||||
val audioAttributes = buildMovieAudioAttributes()
|
||||
val platformAttributes = audioAttributes.getPlatformAudioAttributes()
|
||||
|
||||
var platformSupported: Boolean? = null
|
||||
val platformProbe = try {
|
||||
when {
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> {
|
||||
val platformFormat = buildTrueHdAudioFormat(probeFormat)
|
||||
val support = AudioManager.getDirectPlaybackSupport(platformFormat, platformAttributes)
|
||||
val bitstream = (support and AudioManager.DIRECT_PLAYBACK_BITSTREAM_SUPPORTED) != 0
|
||||
platformSupported = bitstream
|
||||
"api=${Build.VERSION.SDK_INT}, direct=${describeDirectPlaybackSupport(support)}, bitstream=$bitstream"
|
||||
}
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> {
|
||||
val platformFormat = buildTrueHdAudioFormat(probeFormat)
|
||||
val direct = isDirectPlaybackSupportedApi29(platformFormat, platformAttributes)
|
||||
platformSupported = direct
|
||||
"api=${Build.VERSION.SDK_INT}, direct=$direct"
|
||||
}
|
||||
else -> "api=${Build.VERSION.SDK_INT}, direct=unknown"
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
"api=${Build.VERSION.SDK_INT}, direct=error(${e.javaClass.simpleName}: ${e.message})"
|
||||
}
|
||||
|
||||
var media3Supported: Boolean? = null
|
||||
val media3Probe = try {
|
||||
media3Supported = AudioCapabilities
|
||||
.getCapabilities(activity, audioAttributes, null)
|
||||
.isPassthroughPlaybackSupported(probeFormat, audioAttributes)
|
||||
"media3Passthrough=$media3Supported"
|
||||
} catch (e: Exception) {
|
||||
"media3Passthrough=error(${e.javaClass.simpleName}: ${e.message})"
|
||||
}
|
||||
|
||||
val forceFfmpeg = when {
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && platformSupported != null -> !platformSupported
|
||||
media3Supported != null -> !media3Supported
|
||||
platformSupported != null -> !platformSupported
|
||||
else -> false
|
||||
}
|
||||
|
||||
return TrueHdDirectOutputDecision(
|
||||
forceFfmpeg = forceFfmpeg,
|
||||
platformProbe = platformProbe,
|
||||
media3Probe = media3Probe,
|
||||
routeSummary = summarizeAudioOutputRoutes(audioAttributes)
|
||||
)
|
||||
}
|
||||
|
||||
private fun logTrueHdDirectOutputDecision(reason: String, format: Format?, decision: TrueHdDirectOutputDecision) {
|
||||
val key = listOf(
|
||||
decision.forceFfmpeg,
|
||||
decision.platformProbe,
|
||||
decision.media3Probe,
|
||||
decision.routeSummary,
|
||||
format?.id,
|
||||
format?.channelCount,
|
||||
format?.sampleRate
|
||||
).joinToString("|")
|
||||
if (key == lastTrueHdDirectOutputLogKey) return
|
||||
lastTrueHdDirectOutputLogKey = key
|
||||
|
||||
emitLog(
|
||||
"info",
|
||||
"audio",
|
||||
"TrueHD direct output ${if (decision.forceFfmpeg) "disabled (FFmpeg PCM)" else "allowed"} " +
|
||||
"(reason=$reason, format=${formatAudioSummary(trueHdProbeFormat(format))}, " +
|
||||
"${decision.platformProbe}, ${decision.media3Probe}, route=${decision.routeSummary})"
|
||||
)
|
||||
}
|
||||
|
||||
private fun trueHdProbeFormat(format: Format?): Format = if (format?.sampleMimeType == MimeTypes.AUDIO_TRUEHD) {
|
||||
format
|
||||
} else {
|
||||
Format.Builder()
|
||||
.setSampleMimeType(MimeTypes.AUDIO_TRUEHD)
|
||||
.setChannelCount(8)
|
||||
.setSampleRate(48_000)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun buildMovieAudioAttributes(): androidx.media3.common.AudioAttributes = androidx.media3.common.AudioAttributes.Builder()
|
||||
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
|
||||
.setUsage(C.USAGE_MEDIA)
|
||||
.build()
|
||||
|
||||
private fun buildTrueHdAudioFormat(format: Format): AudioFormat {
|
||||
val channelCount = if (format.channelCount > 0) format.channelCount else 8
|
||||
val channelMask = Util.getAudioTrackChannelConfig(channelCount).takeIf { it != 0 }
|
||||
?: AudioFormat.CHANNEL_OUT_7POINT1_SURROUND
|
||||
val sampleRate = if (format.sampleRate > 0) format.sampleRate else 48_000
|
||||
return AudioFormat.Builder()
|
||||
.setEncoding(AudioFormat.ENCODING_DOLBY_TRUEHD)
|
||||
.setChannelMask(channelMask)
|
||||
.setSampleRate(sampleRate)
|
||||
.build()
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun isDirectPlaybackSupportedApi29(
|
||||
audioFormat: AudioFormat,
|
||||
audioAttributes: android.media.AudioAttributes
|
||||
): Boolean = AudioTrack.isDirectPlaybackSupported(audioFormat, audioAttributes)
|
||||
|
||||
private fun describeDirectPlaybackSupport(support: Int): String {
|
||||
if (support == AudioManager.DIRECT_PLAYBACK_NOT_SUPPORTED) return "none"
|
||||
val parts = mutableListOf<String>()
|
||||
if ((support and AudioManager.DIRECT_PLAYBACK_BITSTREAM_SUPPORTED) != 0) parts.add("bitstream")
|
||||
if ((support and AudioManager.DIRECT_PLAYBACK_OFFLOAD_SUPPORTED) != 0) parts.add("offload")
|
||||
if ((support and AudioManager.DIRECT_PLAYBACK_OFFLOAD_GAPLESS_SUPPORTED) != 0) parts.add("offload-gapless")
|
||||
if (parts.isEmpty()) parts.add("0x${support.toString(16)}")
|
||||
return parts.joinToString("+")
|
||||
}
|
||||
|
||||
private fun summarizeAudioOutputRoutes(audioAttributes: androidx.media3.common.AudioAttributes): String = try {
|
||||
val audioManager = activity.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
val routedDevices = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
audioManager.getAudioDevicesForAttributes(audioAttributes.getPlatformAudioAttributes())
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
val devices = if (routedDevices.isNotEmpty()) {
|
||||
routedDevices
|
||||
} else {
|
||||
audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS).toList()
|
||||
}
|
||||
val source = if (routedDevices.isNotEmpty()) "routed" else "outputs"
|
||||
if (devices.isEmpty()) {
|
||||
"$source=none"
|
||||
} else {
|
||||
val extra = if (devices.size > 3) ";+${devices.size - 3}" else ""
|
||||
"$source=${devices.take(3).joinToString(";") { describeAudioDevice(it) }}$extra"
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
"error(${e.javaClass.simpleName}: ${e.message})"
|
||||
}
|
||||
|
||||
private fun describeAudioDevice(device: AudioDeviceInfo): String {
|
||||
val encodings = device.encodings.takeIf { it.isNotEmpty() }
|
||||
?.joinToString("|") { audioEncodingName(it) }
|
||||
?: "any/unknown"
|
||||
val channels = device.channelCounts.takeIf { it.isNotEmpty() }
|
||||
?.joinToString("|")
|
||||
?: "unknown"
|
||||
val name = device.productName?.toString()?.take(32) ?: "unknown"
|
||||
return "${audioDeviceTypeName(device.type)}($name,enc=$encodings,ch=$channels)"
|
||||
}
|
||||
|
||||
private fun audioEncodingName(encoding: Int): String = when (encoding) {
|
||||
AudioFormat.ENCODING_PCM_16BIT -> "pcm16"
|
||||
AudioFormat.ENCODING_PCM_FLOAT -> "pcm-float"
|
||||
AudioFormat.ENCODING_AC3 -> "ac3"
|
||||
AudioFormat.ENCODING_E_AC3 -> "eac3"
|
||||
AudioFormat.ENCODING_E_AC3_JOC -> "eac3-joc"
|
||||
AudioFormat.ENCODING_DTS -> "dts"
|
||||
AudioFormat.ENCODING_DTS_HD -> "dts-hd"
|
||||
AudioFormat.ENCODING_DOLBY_TRUEHD -> "truehd"
|
||||
else -> encoding.toString()
|
||||
}
|
||||
|
||||
private fun audioDeviceTypeName(type: Int): String = when (type) {
|
||||
AudioDeviceInfo.TYPE_BUILTIN_SPEAKER -> "speaker"
|
||||
AudioDeviceInfo.TYPE_HDMI -> "hdmi"
|
||||
AudioDeviceInfo.TYPE_HDMI_ARC -> "hdmi-arc"
|
||||
AudioDeviceInfo.TYPE_HDMI_EARC -> "hdmi-earc"
|
||||
AudioDeviceInfo.TYPE_LINE_DIGITAL -> "line-digital"
|
||||
AudioDeviceInfo.TYPE_LINE_ANALOG -> "line-analog"
|
||||
AudioDeviceInfo.TYPE_USB_DEVICE -> "usb-device"
|
||||
AudioDeviceInfo.TYPE_USB_HEADSET -> "usb-headset"
|
||||
AudioDeviceInfo.TYPE_BLUETOOTH_A2DP -> "bt-a2dp"
|
||||
AudioDeviceInfo.TYPE_BLUETOOTH_SCO -> "bt-sco"
|
||||
AudioDeviceInfo.TYPE_UNKNOWN -> "unknown"
|
||||
else -> "type-$type"
|
||||
}
|
||||
|
||||
private fun isEac3MimeType(mimeType: String): Boolean = mimeType == MimeTypes.AUDIO_E_AC3 || mimeType == MimeTypes.AUDIO_E_AC3_JOC
|
||||
|
||||
private fun hasHardwareAudioDecoder(mimeType: String): Boolean {
|
||||
@@ -1231,6 +1443,18 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
||||
return mediaItemBuilder.build()
|
||||
}
|
||||
|
||||
private fun formatAudioSummary(format: Format): String {
|
||||
val parts = mutableListOf<String>()
|
||||
parts.add("mime=${format.sampleMimeType ?: "unknown"}")
|
||||
parts.add("codec=${format.codecs ?: "unknown"}")
|
||||
parts.add("channels=${format.channelCount}")
|
||||
parts.add("sampleRate=${format.sampleRate}")
|
||||
format.id?.let { parts.add("id=$it") }
|
||||
format.label?.let { parts.add("label=$it") }
|
||||
format.language?.let { parts.add("lang=$it") }
|
||||
return parts.joinToString(", ")
|
||||
}
|
||||
|
||||
// Decoder hang detection via AnalyticsListener:
|
||||
// Tracks the gap between onVideoDecoderInitialized and onRenderedFirstFrame.
|
||||
// If the decoder is initialized and fed input but never produces output, it's hung
|
||||
@@ -1254,6 +1478,75 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
||||
initializationDurationMs: Long
|
||||
) {
|
||||
audioDecoderInitName = decoderName
|
||||
emitLog("info", "audio", "Decoder initialized: $decoderName (${initializationDurationMs}ms)")
|
||||
}
|
||||
|
||||
override fun onAudioInputFormatChanged(
|
||||
eventTime: AnalyticsListener.EventTime,
|
||||
format: Format,
|
||||
decoderReuseEvaluation: DecoderReuseEvaluation?
|
||||
) {
|
||||
emitLog("info", "audio", "Input format: ${formatAudioSummary(format)}")
|
||||
if (format.sampleMimeType == MimeTypes.AUDIO_TRUEHD) {
|
||||
updateAudioDecoderPolicy("input format", format)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAudioPositionAdvancing(
|
||||
eventTime: AnalyticsListener.EventTime,
|
||||
playoutStartSystemTimeMs: Long
|
||||
) {
|
||||
emitLog("debug", "audio", "Position advancing: playoutStart=$playoutStartSystemTimeMs")
|
||||
}
|
||||
|
||||
override fun onAudioUnderrun(
|
||||
eventTime: AnalyticsListener.EventTime,
|
||||
bufferSize: Int,
|
||||
bufferSizeMs: Long,
|
||||
elapsedSinceLastFeedMs: Long
|
||||
) {
|
||||
emitLog(
|
||||
"warn",
|
||||
"audio",
|
||||
"Underrun: buffer=${bufferSize}B, bufferMs=$bufferSizeMs, elapsedSinceLastFeedMs=$elapsedSinceLastFeedMs"
|
||||
)
|
||||
}
|
||||
|
||||
override fun onAudioSinkError(eventTime: AnalyticsListener.EventTime, audioSinkError: Exception) {
|
||||
emitLog("warn", "audio", "Sink error: ${audioSinkError.javaClass.name}: ${audioSinkError.message}")
|
||||
}
|
||||
|
||||
override fun onAudioCodecError(eventTime: AnalyticsListener.EventTime, audioCodecError: Exception) {
|
||||
emitLog("warn", "audio", "Codec error: ${audioCodecError.javaClass.name}: ${audioCodecError.message}")
|
||||
}
|
||||
|
||||
override fun onAudioTrackInitialized(
|
||||
eventTime: AnalyticsListener.EventTime,
|
||||
audioTrackConfig: AudioSink.AudioTrackConfig
|
||||
) {
|
||||
emitLog(
|
||||
"info",
|
||||
"audio",
|
||||
"AudioTrack initialized: encoding=${audioTrackConfig.encoding}, sampleRate=${audioTrackConfig.sampleRate}, " +
|
||||
"channelConfig=0x${audioTrackConfig.channelConfig.toString(16)}, buffer=${audioTrackConfig.bufferSize}B, " +
|
||||
"tunneling=${audioTrackConfig.tunneling}, offload=${audioTrackConfig.offload}"
|
||||
)
|
||||
}
|
||||
|
||||
override fun onAudioTrackReleased(
|
||||
eventTime: AnalyticsListener.EventTime,
|
||||
audioTrackConfig: AudioSink.AudioTrackConfig
|
||||
) {
|
||||
emitLog(
|
||||
"debug",
|
||||
"audio",
|
||||
"AudioTrack released: encoding=${audioTrackConfig.encoding}, sampleRate=${audioTrackConfig.sampleRate}, " +
|
||||
"channelConfig=0x${audioTrackConfig.channelConfig.toString(16)}, buffer=${audioTrackConfig.bufferSize}B"
|
||||
)
|
||||
}
|
||||
|
||||
override fun onVolumeChanged(eventTime: AnalyticsListener.EventTime, volume: Float) {
|
||||
emitLog("debug", "audio", "Volume changed: $volume")
|
||||
}
|
||||
|
||||
override fun onRenderedFirstFrame(
|
||||
@@ -1404,6 +1697,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
||||
|
||||
decoderInitName = null
|
||||
audioDecoderInitName = null
|
||||
updateAudioDecoderPolicy("open")
|
||||
currentMediaUri = uri
|
||||
currentHeaders = headers
|
||||
currentMediaIsLive = isLive
|
||||
@@ -1502,7 +1796,6 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
||||
dv7RetryAttempted = override != null
|
||||
activeDoviMkvWrapper = null
|
||||
activeDoviMp4Wrapper = null
|
||||
|
||||
val debugMode = override?.name ?: "AUTO"
|
||||
emitLog("info", "dv-debug", "Debug DV conversion mode set to $debugMode (active=$dvMode)")
|
||||
reloadCurrentMediaForDvMode()
|
||||
|
||||
@@ -4,6 +4,8 @@ import android.content.Context
|
||||
import android.media.AudioDeviceInfo
|
||||
import android.os.Build
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.Format
|
||||
import androidx.media3.common.MimeTypes
|
||||
import androidx.media3.common.PlaybackParameters
|
||||
import androidx.media3.common.util.Clock
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
@@ -27,6 +29,14 @@ class PlezyRenderersFactory(context: Context) : DefaultRenderersFactory(context)
|
||||
/** Audio delay in microseconds. Shared with PositionFixAudioSink for live updates. */
|
||||
val audioDelayUs = AtomicLong(0L)
|
||||
|
||||
/** Returns whether direct TrueHD output should be hidden so FFmpeg PCM decode can be selected. */
|
||||
var shouldBlockDirectTrueHd: ((Format) -> Boolean)? = null
|
||||
|
||||
/** Called before Media3 reacts to an audio route/capability change. */
|
||||
var onAudioCapabilitiesChanged: (() -> Unit)? = null
|
||||
|
||||
var audioDiagnosticsLogger: ((String, String, String) -> Unit)? = null
|
||||
|
||||
override fun buildAudioSink(
|
||||
context: Context,
|
||||
enableFloatOutput: Boolean,
|
||||
@@ -51,10 +61,17 @@ class PlezyRenderersFactory(context: Context) : DefaultRenderersFactory(context)
|
||||
val defaultSink = DefaultAudioSink.Builder(context)
|
||||
.setEnableFloatOutput(enableFloatOutput)
|
||||
.setEnableAudioOutputPlaybackParameters(enableAudioOutputPlaybackParams)
|
||||
.setAudioOutputProvider(RawPositionOutputProvider(realProvider, rawPositionUs))
|
||||
.setAudioOutputProvider(RawPositionOutputProvider(realProvider, rawPositionUs, audioDiagnosticsLogger))
|
||||
.build()
|
||||
|
||||
return PositionFixAudioSink(defaultSink, rawPositionUs, audioDelayUs)
|
||||
return PositionFixAudioSink(
|
||||
defaultSink,
|
||||
rawPositionUs,
|
||||
audioDelayUs,
|
||||
shouldBlockDirectTrueHd,
|
||||
onAudioCapabilitiesChanged,
|
||||
audioDiagnosticsLogger
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +100,10 @@ class PlezyRenderersFactory(context: Context) : DefaultRenderersFactory(context)
|
||||
private class PositionFixAudioSink(
|
||||
sink: AudioSink,
|
||||
private val rawPositionUs: AtomicLong,
|
||||
private val audioDelayUs: AtomicLong
|
||||
private val audioDelayUs: AtomicLong,
|
||||
private val shouldBlockDirectTrueHd: ((Format) -> Boolean)?,
|
||||
private val onAudioCapabilitiesChanged: (() -> Unit)?,
|
||||
private val log: ((String, String, String) -> Unit)?
|
||||
) : ForwardingAudioSink(sink) {
|
||||
|
||||
private var startMediaTimeUs = Long.MIN_VALUE
|
||||
@@ -97,12 +117,45 @@ private class PositionFixAudioSink(
|
||||
// Transient counter-offset: after seek/flush, ramp offset from 0 to full
|
||||
// over recoveryDurationUs to prevent video frame drops.
|
||||
private var recoveryDurationUs = 0L
|
||||
private var loggedTrueHdDirectBlock = false
|
||||
private var loggedFirstBuffer = false
|
||||
|
||||
override fun supportsFormat(format: Format): Boolean {
|
||||
if (blocksDirectTrueHd(format)) return false
|
||||
return super.supportsFormat(format)
|
||||
}
|
||||
|
||||
override fun getFormatSupport(format: Format): Int {
|
||||
if (blocksDirectTrueHd(format)) return AudioSink.SINK_FORMAT_UNSUPPORTED
|
||||
return super.getFormatSupport(format)
|
||||
}
|
||||
|
||||
private fun blocksDirectTrueHd(format: Format): Boolean {
|
||||
if (format.sampleMimeType != MimeTypes.AUDIO_TRUEHD || shouldBlockDirectTrueHd?.invoke(format) != true) return false
|
||||
if (!loggedTrueHdDirectBlock) {
|
||||
loggedTrueHdDirectBlock = true
|
||||
log?.invoke(
|
||||
"info",
|
||||
"audio",
|
||||
"Blocking direct TrueHD output; route does not report TrueHD bitstream support, FFmpeg PCM decode will be preferred"
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun handleBuffer(
|
||||
buffer: ByteBuffer,
|
||||
presentationTimeUs: Long,
|
||||
encodedAccessUnitCount: Int
|
||||
): Boolean {
|
||||
if (!loggedFirstBuffer && buffer.hasRemaining()) {
|
||||
loggedFirstBuffer = true
|
||||
log?.invoke(
|
||||
"debug",
|
||||
"audio-sink",
|
||||
"First buffer: size=${buffer.remaining()}B, pts=${presentationTimeUs}us, accessUnits=$encodedAccessUnitCount"
|
||||
)
|
||||
}
|
||||
if (startMediaTimeUs == Long.MIN_VALUE) {
|
||||
startMediaTimeUs = presentationTimeUs
|
||||
refMediaTimeUs = presentationTimeUs
|
||||
@@ -163,7 +216,10 @@ private class PositionFixAudioSink(
|
||||
override fun onSkipSilenceEnabledChanged(skipSilenceEnabled: Boolean) = listener.onSkipSilenceEnabledChanged(skipSilenceEnabled)
|
||||
override fun onOffloadBufferEmptying() = listener.onOffloadBufferEmptying()
|
||||
override fun onOffloadBufferFull() = listener.onOffloadBufferFull()
|
||||
override fun onAudioCapabilitiesChanged() = listener.onAudioCapabilitiesChanged()
|
||||
override fun onAudioCapabilitiesChanged() {
|
||||
onAudioCapabilitiesChanged?.invoke()
|
||||
listener.onAudioCapabilitiesChanged()
|
||||
}
|
||||
override fun onAudioTrackInitialized(audioTrackConfig: AudioSink.AudioTrackConfig) = listener.onAudioTrackInitialized(audioTrackConfig)
|
||||
override fun onAudioTrackReleased(audioTrackConfig: AudioSink.AudioTrackConfig) = listener.onAudioTrackReleased(audioTrackConfig)
|
||||
override fun onSilenceSkipped() = listener.onSilenceSkipped()
|
||||
@@ -235,7 +291,8 @@ internal class SubtitleDelayRenderer(
|
||||
@OptIn(UnstableApi::class)
|
||||
private class RawPositionOutputProvider(
|
||||
private val delegate: AudioOutputProvider,
|
||||
private val rawPositionUs: AtomicLong
|
||||
private val rawPositionUs: AtomicLong,
|
||||
private val log: ((String, String, String) -> Unit)?
|
||||
) : AudioOutputProvider {
|
||||
|
||||
private var cachedOutput: RawPositionAudioOutput? = null
|
||||
@@ -256,7 +313,7 @@ private class RawPositionOutputProvider(
|
||||
|
||||
val realOutput = delegate.getAudioOutput(config)
|
||||
cachedConfig = config
|
||||
return RawPositionAudioOutput(realOutput, rawPositionUs, this)
|
||||
return RawPositionAudioOutput(realOutput, rawPositionUs, this, log)
|
||||
}
|
||||
|
||||
fun returnToCache(output: RawPositionAudioOutput) {
|
||||
@@ -285,9 +342,14 @@ private class RawPositionOutputProvider(
|
||||
private class RawPositionAudioOutput(
|
||||
private val delegate: AudioOutput,
|
||||
private val rawPositionUs: AtomicLong,
|
||||
private val provider: RawPositionOutputProvider
|
||||
private val provider: RawPositionOutputProvider,
|
||||
private val log: ((String, String, String) -> Unit)?
|
||||
) : AudioOutput {
|
||||
|
||||
private var loggedFirstWrite = false
|
||||
private var writeCount = 0L
|
||||
private var writtenBytes = 0L
|
||||
|
||||
override fun getPositionUs(): Long {
|
||||
val pos = delegate.getPositionUs()
|
||||
rawPositionUs.set(pos)
|
||||
@@ -298,10 +360,30 @@ private class RawPositionAudioOutput(
|
||||
override fun pause() = delegate.pause()
|
||||
|
||||
@Throws(AudioOutput.WriteException::class)
|
||||
override fun write(buffer: ByteBuffer, size: Int, presentationTimeUs: Long) = delegate.write(buffer, size, presentationTimeUs)
|
||||
override fun write(buffer: ByteBuffer, size: Int, presentationTimeUs: Long): Boolean {
|
||||
val before = buffer.position()
|
||||
val handled = delegate.write(buffer, size, presentationTimeUs)
|
||||
val written = buffer.position() - before
|
||||
if (written > 0) {
|
||||
writeCount++
|
||||
writtenBytes += written.toLong()
|
||||
if (!loggedFirstWrite) {
|
||||
loggedFirstWrite = true
|
||||
log?.invoke(
|
||||
"debug",
|
||||
"audio-output",
|
||||
"First write: wrote=${written}B, handled=$handled, pts=${presentationTimeUs}us, writes=$writeCount, total=${writtenBytes}B"
|
||||
)
|
||||
}
|
||||
}
|
||||
return handled
|
||||
}
|
||||
|
||||
override fun flush() {
|
||||
rawPositionUs.set(Long.MIN_VALUE)
|
||||
loggedFirstWrite = false
|
||||
writeCount = 0
|
||||
writtenBytes = 0
|
||||
delegate.flush()
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user