From d20718513040c781bf485f8a0d6fb4d6ad3f31c5 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sat, 7 Mar 2026 16:27:29 +0100 Subject: [PATCH] fix: bypass DefaultAudioSink position clamp for large audio frames Fixes video stutter on files with 120ms Opus audio frames (e.g. Amlogic S905X4). DefaultAudioSink clamps getCurrentPositionUs to writtenDuration which lags between bursts, creating a sawtooth pattern that drops video frames. PositionFixAudioSink bypasses the clamp using the raw AudioTrack position when the delegate falls behind. --- .../edde746/plezy/exoplayer/ExoPlayerCore.kt | 3 +- .../plezy/exoplayer/PlezyRenderersFactory.kt | 234 ++++++++++++++++++ 2 files changed, 235 insertions(+), 2 deletions(-) create mode 100644 android/app/src/main/kotlin/com/edde746/plezy/exoplayer/PlezyRenderersFactory.kt 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 833057ad..d4e2027f 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 @@ -335,10 +335,9 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { .build() // Use DefaultRenderersFactory with FFmpeg fallback for unsupported audio codecs - val renderersFactory = DefaultRenderersFactory(activity).apply { + val renderersFactory = PlezyRenderersFactory(activity).apply { setEnableDecoderFallback(true) setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON) - forceDisableMediaCodecAsynchronousQueueing() // Force FFmpeg for FLAC — hardware FLAC decoders (e.g. Samsung c2.sec.flac.decoder) // have buggy 32KB input buffer limits causing InsufficientCapacityException. setMediaCodecSelector { mimeType, requiresSecureDecoder, requiresTunnelingDecoder -> 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 new file mode 100644 index 00000000..7c97723c --- /dev/null +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/PlezyRenderersFactory.kt @@ -0,0 +1,234 @@ +package com.edde746.plezy.exoplayer + +import android.content.Context +import android.media.AudioDeviceInfo +import androidx.annotation.OptIn +import androidx.media3.common.PlaybackParameters +import androidx.media3.common.util.Clock +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.DefaultRenderersFactory +import androidx.media3.exoplayer.analytics.PlayerId +import androidx.media3.exoplayer.audio.AudioOutput +import androidx.media3.exoplayer.audio.AudioOutputProvider +import androidx.media3.exoplayer.audio.AudioSink +import androidx.media3.exoplayer.audio.AudioTrackAudioOutputProvider +import androidx.media3.exoplayer.audio.DefaultAudioSink +import androidx.media3.exoplayer.audio.DefaultAudioTrackBufferSizeProvider +import androidx.media3.exoplayer.audio.ForwardingAudioSink +import java.nio.ByteBuffer +import java.util.concurrent.atomic.AtomicLong + +@OptIn(UnstableApi::class) +class PlezyRenderersFactory(context: Context) : DefaultRenderersFactory(context) { + + override fun buildAudioSink( + context: Context, + enableFloatOutput: Boolean, + enableAudioOutputPlaybackParams: Boolean + ): AudioSink { + AudioTrackAudioOutputProvider.failOnSpuriousAudioTimestamp = false + + val bufferSizeProvider = DefaultAudioTrackBufferSizeProvider.Builder() + .setMinPcmBufferDurationUs(500_000) + .setMaxPcmBufferDurationUs(1_500_000) + .setPcmBufferMultiplicationFactor(8) + .build() + + val realProvider = AudioTrackAudioOutputProvider.Builder(context) + .setAudioTrackBufferSizeProvider(bufferSizeProvider) + .build() + + // Shared position: RawPositionAudioOutput writes the raw AudioTrack position, + // PositionFixAudioSink reads it to bypass DefaultAudioSink's writtenDuration clamp. + val rawPositionUs = AtomicLong(Long.MIN_VALUE) + + val defaultSink = DefaultAudioSink.Builder(context) + .setEnableFloatOutput(enableFloatOutput) + .setEnableAudioOutputPlaybackParameters(enableAudioOutputPlaybackParams) + .setAudioOutputProvider(RawPositionOutputProvider(realProvider, rawPositionUs)) + .build() + + return PositionFixAudioSink(defaultSink, rawPositionUs) + } +} + +/** + * Fixes video stutter with large audio frames (e.g. 120ms Opus). + * + * DefaultAudioSink.getCurrentPositionUs() clamps the playback position to + * writtenDuration via Math.min(positionUs, framesToDurationUs(writtenFrames)). + * With large audio frames, data arrives in bursts and writtenDuration lags behind + * the real AudioTrack playback position between bursts, creating a sawtooth + * position pattern (plateau → jump) that makes the video renderer drop frames. + * + * Fix: when DefaultAudioSink's position falls behind the raw AudioTrack position, + * return startMediaTimeUs + rawPosition instead, giving the video renderer a + * smooth clock to sync against. + * + * Also suppresses Amlogic AudioTrack timestamp discontinuity errors. + * + * Note: this bypass does not apply speed conversion, so it is only accurate at + * 1.0x playback speed. At other speeds, the delegate position is used. + */ +@OptIn(UnstableApi::class) +private class PositionFixAudioSink( + sink: AudioSink, + private val rawPositionUs: AtomicLong +) : ForwardingAudioSink(sink) { + + private var startMediaTimeUs = Long.MIN_VALUE + private var suppressedErrorCount = 0 + + override fun handleBuffer( + buffer: ByteBuffer, + presentationTimeUs: Long, + encodedAccessUnitCount: Int + ): Boolean { + if (startMediaTimeUs == Long.MIN_VALUE) { + startMediaTimeUs = presentationTimeUs + } + return super.handleBuffer(buffer, presentationTimeUs, encodedAccessUnitCount) + } + + override fun getCurrentPositionUs(sourceEnded: Boolean): Long { + val delegatePos = super.getCurrentPositionUs(sourceEnded) + if (delegatePos == Long.MIN_VALUE || startMediaTimeUs == Long.MIN_VALUE) { + return delegatePos + } + + val rawPos = rawPositionUs.get() + if (rawPos <= 0) return delegatePos + + val expectedPos = startMediaTimeUs + rawPos + return if (expectedPos > delegatePos + 30_000) expectedPos else delegatePos + } + + // --- Suppress timestamp discontinuity errors --- + + override fun setListener(listener: AudioSink.Listener) { + super.setListener(@OptIn(UnstableApi::class) object : AudioSink.Listener { + override fun onPositionDiscontinuity() = listener.onPositionDiscontinuity() + override fun onPositionAdvancing(playoutStartSystemTimeUs: Long) = listener.onPositionAdvancing(playoutStartSystemTimeUs) + override fun onUnderrun(bufferSize: Int, bufferSizeMs: Long, elapsedSinceLastFeedMs: Long) = listener.onUnderrun(bufferSize, bufferSizeMs, elapsedSinceLastFeedMs) + 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 onAudioTrackInitialized(audioTrackConfig: AudioSink.AudioTrackConfig) = listener.onAudioTrackInitialized(audioTrackConfig) + override fun onAudioTrackReleased(audioTrackConfig: AudioSink.AudioTrackConfig) = listener.onAudioTrackReleased(audioTrackConfig) + override fun onSilenceSkipped() = listener.onSilenceSkipped() + override fun onAudioSessionIdChanged(audioSessionId: Int) = listener.onAudioSessionIdChanged(audioSessionId) + + override fun onAudioSinkError(audioSinkError: Exception) { + if (isTimestampDiscontinuity(audioSinkError)) { + suppressedErrorCount++ + return + } + listener.onAudioSinkError(audioSinkError) + } + }) + } + + override fun flush() { + startMediaTimeUs = Long.MIN_VALUE + rawPositionUs.set(Long.MIN_VALUE) + super.flush() + } + + override fun reset() { + startMediaTimeUs = Long.MIN_VALUE + rawPositionUs.set(Long.MIN_VALUE) + suppressedErrorCount = 0 + super.reset() + } + + private fun isTimestampDiscontinuity(e: Exception): Boolean { + val name = e.javaClass.simpleName + val msg = e.message ?: "" + return name == "InvalidAudioTrackTimestampException" || + name == "UnexpectedDiscontinuityException" || + msg.contains("timestamp discontinuity", ignoreCase = true) + } +} + +// --- AudioOutput wrapping: shares raw position with PositionFixAudioSink --- + +@OptIn(UnstableApi::class) +private class RawPositionOutputProvider( + private val delegate: AudioOutputProvider, + private val rawPositionUs: AtomicLong +) : AudioOutputProvider { + + override fun getFormatSupport(config: AudioOutputProvider.FormatConfig) = + delegate.getFormatSupport(config) + + override fun getOutputConfig(config: AudioOutputProvider.FormatConfig) = + delegate.getOutputConfig(config) + + override fun getAudioOutput(config: AudioOutputProvider.OutputConfig): AudioOutput { + val realOutput = delegate.getAudioOutput(config) + return RawPositionAudioOutput(realOutput, rawPositionUs) + } + + override fun addListener(listener: AudioOutputProvider.Listener) = + delegate.addListener(listener) + + override fun removeListener(listener: AudioOutputProvider.Listener) = + delegate.removeListener(listener) + + override fun setClock(clock: Clock) = delegate.setClock(clock) + + override fun release() = delegate.release() +} + +@OptIn(UnstableApi::class) +private class RawPositionAudioOutput( + private val delegate: AudioOutput, + private val rawPositionUs: AtomicLong +) : AudioOutput { + + override fun getPositionUs(): Long { + val pos = delegate.getPositionUs() + rawPositionUs.set(pos) + return pos + } + + override fun play() = delegate.play() + 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 flush() { + rawPositionUs.set(Long.MIN_VALUE) + delegate.flush() + } + + override fun stop() = delegate.stop() + + override fun release() { + rawPositionUs.set(Long.MIN_VALUE) + delegate.release() + } + + override fun setVolume(volume: Float) = delegate.setVolume(volume) + override fun isOffloadedPlayback() = delegate.isOffloadedPlayback() + override fun getAudioSessionId() = delegate.getAudioSessionId() + override fun getSampleRate() = delegate.getSampleRate() + override fun getBufferSizeInFrames() = delegate.getBufferSizeInFrames() + override fun getPlaybackParameters() = delegate.getPlaybackParameters() + override fun isStalled() = delegate.isStalled() + override fun addListener(listener: AudioOutput.Listener) = delegate.addListener(listener) + override fun removeListener(listener: AudioOutput.Listener) = delegate.removeListener(listener) + override fun setPlaybackParameters(playbackParameters: PlaybackParameters) = + delegate.setPlaybackParameters(playbackParameters) + override fun setOffloadDelayPadding(delayInFrames: Int, paddingInFrames: Int) = + delegate.setOffloadDelayPadding(delayInFrames, paddingInFrames) + override fun setOffloadEndOfStream() = delegate.setOffloadEndOfStream() + override fun setPlayerId(playerId: PlayerId) = delegate.setPlayerId(playerId) + override fun attachAuxEffect(effectId: Int) = delegate.attachAuxEffect(effectId) + override fun setAuxEffectSendLevel(level: Float) = delegate.setAuxEffectSendLevel(level) + override fun setPreferredDevice(preferredDevice: AudioDeviceInfo?) = + delegate.setPreferredDevice(preferredDevice) +}