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.
This commit is contained in:
edde746
2026-03-07 16:27:29 +01:00
parent 66628e1bca
commit d207185130
2 changed files with 235 additions and 2 deletions
@@ -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 ->
@@ -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)
}