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 1a02eba4..5d7415aa 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 @@ -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() + 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() + 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() diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/PlezyRenderersFactory.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/PlezyRenderersFactory.kt index 859f0844..ca29de06 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/PlezyRenderersFactory.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/PlezyRenderersFactory.kt @@ -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() }