fix(player): move TrueHD off the carrier when playback speed leaves 1x

A bitstream cannot be resampled, so the carrier only ever accepts 1x. The
selection gate covered that, but nothing re-ran it: setPlaybackSpeed reaches
the sink and returns, and the renderer only re-asks when audio capabilities are
invalidated. A speed change during carrier playback therefore left the carrier
live and handed it parameters its empty processor chain cannot apply.

Signal the capability change from the sink, which reaches
onRendererCapabilitiesChanged and moves TrueHD onto the decoder; returning to
1x re-offers the carrier, so a speed nudge no longer costs Atmos for the rest
of the session. The carrier delegate is never given a non-1x speed while that
selection is in flight.

Report the requested parameters rather than the delegate's while the carrier is
active. The player polls the sink through the media clock and adopts what it
reads, so reporting the pinned 1x pushed it back into the player and silently
undid the speed change.

Rebuilding the track selector parameters is not an alternative:
DefaultTrackSelector skips invalidation when the rebuilt parameters compare
equal, so a forced reselection can silently no-op.

Verified on the SEI Box R (Android 14, armv7): carrier at 192kHz with no
decoder, speed to 1.5x moves it to the FFmpeg decoder at 48kHz with the clock
advancing faster than real time, and returning to 1x restores the carrier. The
device test skips itself on hardware that never takes the carrier, as the
Nvidia Shield does.
This commit is contained in:
edde746
2026-08-08 10:51:15 +02:00
parent 3b76cf3948
commit be99f27b92
4 changed files with 311 additions and 7 deletions
@@ -0,0 +1,195 @@
package com.edde746.plezy.exoplayer
import android.net.Uri
import android.os.Handler
import android.os.HandlerThread
import android.util.Log
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.analytics.AnalyticsListener
import androidx.media3.exoplayer.audio.AudioSink
import androidx.media3.exoplayer.source.ProgressiveMediaSource
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import androidx.media3.extractor.DefaultExtractorsFactory
import androidx.test.platform.app.InstrumentationRegistry
import java.io.File
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Speed changes arrive while the carrier is already live (#1804). Nothing re-asks the sink unless
* capabilities are invalidated, so this drives the real path: play on the carrier, change speed,
* and require the renderer to actually move TrueHD onto a decoder and keep the clock advancing.
*
* Skips itself on hardware that never takes the carrier; there is no transition to observe there.
*/
class TrueHdSpeedTransitionTest {
private companion object {
const val TAG = "TrueHdSpeed"
const val SETTLE_MS = 4_000L
}
@Test
fun leavingUnitSpeedMovesTrueHdOffTheCarrierAndKeepsPlaying() {
val instrumentation = InstrumentationRegistry.getInstrumentation()
val context = instrumentation.targetContext
val fixture = copyFixture(context)
val thread = HandlerThread("truehd-speed-test").apply { start() }
val handler = Handler(thread.looper)
val playing = CountDownLatch(1)
val audioDecoder = AtomicReference<String?>(null)
val trackEncoding = AtomicReference<Int>(-1)
val player = AtomicReference<ExoPlayer?>(null)
handler.post {
val factory = PlezyRenderersFactory(context).apply {
setEnableDecoderFallback(true)
setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON)
}
// Mirrors ExoPlayerCore: without this flag DefaultTrackSelector silently drops every
// renderer-capability invalidation, so the carrier could never be re-evaluated.
val selector = DefaultTrackSelector(context).apply {
setParameters(
buildUponParameters().setAllowInvalidateSelectionsOnRendererCapabilitiesChange(true)
)
}
val exo = ExoPlayer.Builder(context, factory).setTrackSelector(selector).build()
player.set(exo)
exo.addAnalyticsListener(
object : AnalyticsListener {
override fun onAudioDecoderInitialized(
eventTime: AnalyticsListener.EventTime,
decoderName: String,
initializedTimestampMs: Long,
initializationDurationMs: Long
) {
audioDecoder.set(decoderName)
Log.i(TAG, "audio decoder: $decoderName")
}
override fun onAudioTrackInitialized(
eventTime: AnalyticsListener.EventTime,
config: AudioSink.AudioTrackConfig
) {
trackEncoding.set(config.sampleRate)
Log.i(TAG, "AudioTrack rate=${config.sampleRate} buffer=${config.bufferSize}")
}
override fun onIsPlayingChanged(eventTime: AnalyticsListener.EventTime, isPlaying: Boolean) {
if (isPlaying) playing.countDown()
}
}
)
val source = ProgressiveMediaSource.Factory(
DefaultDataSource.Factory(context),
DefaultExtractorsFactory()
).createMediaSource(MediaItem.fromUri(Uri.fromFile(fixture)))
exo.setMediaSource(source)
exo.prepare()
exo.playWhenReady = true
}
assertTrue("playback never started", playing.await(30, TimeUnit.SECONDS))
Thread.sleep(SETTLE_MS)
val carrierEncoding = trackEncoding.get()
val decoderBefore = audioDecoder.get()
Log.i(TAG, "==== BEFORE: rate=$carrierEncoding decoder=$decoderBefore ====")
// AudioTrackConfig is built from OutputConfig, which stays PCM16 by design; the real
// AudioTrack format is swapped in the builder modifier. The observable carrier signature is
// therefore the 192kHz carrier rate with no decoder instantiated.
if (carrierEncoding != TrueHdMatPacker.CARRIER_SAMPLE_RATE || decoderBefore != null) {
Log.i(TAG, "==== SKIPPED: device does not take the carrier (rate=$carrierEncoding) ====")
teardown(handler, player, thread, fixture)
return
}
val positionBefore = positionOf(handler, player)
handler.post { player.get()?.setPlaybackSpeed(1.5f) }
Thread.sleep(SETTLE_MS)
val encodingAfter = trackEncoding.get()
val decoderAfter = audioDecoder.get()
val positionAfter = positionOf(handler, player)
Log.i(
TAG,
"==== AFTER: rate=$encodingAfter decoder=$decoderAfter " +
"position=${positionBefore}ms -> ${positionAfter}ms ===="
)
// Returning to 1x has to re-offer the carrier, or one speed nudge costs Atmos for the session.
trackEncoding.set(-1)
handler.post { player.get()?.setPlaybackSpeed(1f) }
Thread.sleep(SETTLE_MS)
val rateRestored = trackEncoding.get()
Log.i(TAG, "==== RESTORED: rate=$rateRestored ====")
teardown(handler, player, thread, fixture)
assertEquals(
"returning to 1x must put TrueHD back on the carrier",
TrueHdMatPacker.CARRIER_SAMPLE_RATE,
rateRestored
)
assertNotEquals(
"TrueHD must leave the IEC 61937 carrier when speed leaves 1x",
TrueHdMatPacker.CARRIER_SAMPLE_RATE,
encodingAfter
)
assertTrue("a decoder must take over the TrueHD track", decoderAfter != null)
assertTrue("the clock must keep advancing after the switch", positionAfter > positionBefore)
// The whole point of the switch: the speed the user asked for has to actually apply. At 1.5x
// the media clock must outrun the 4s of wall time spent waiting.
assertTrue(
"playback must run faster than real time after the switch, advanced " +
"${positionAfter - positionBefore}ms in ${SETTLE_MS}ms",
positionAfter - positionBefore > SETTLE_MS * 6 / 5
)
}
private fun positionOf(handler: Handler, player: AtomicReference<ExoPlayer?>): Long {
val value = AtomicReference(0L)
val done = CountDownLatch(1)
handler.post {
value.set(player.get()?.currentPosition ?: 0L)
done.countDown()
}
done.await(5, TimeUnit.SECONDS)
return value.get()
}
private fun teardown(
handler: Handler,
player: AtomicReference<ExoPlayer?>,
thread: HandlerThread,
fixture: File
) {
val done = CountDownLatch(1)
handler.post {
player.get()?.release()
done.countDown()
}
done.await(10, TimeUnit.SECONDS)
thread.quitSafely()
thread.join(5_000)
fixture.delete()
}
private fun copyFixture(context: android.content.Context): File {
val output = File.createTempFile("truehd-speed-", null, context.cacheDir)
InstrumentationRegistry.getInstrumentation().context.assets
.open("ffmpeg/truehd_speed_repro.mka")
.use { input -> output.outputStream().use { input.copyTo(it) } }
return output
}
}
@@ -76,6 +76,7 @@ internal class TrueHdCarrierSink(
private var burstsSinceAnchor: Long = 0
private var playbackParameters: PlaybackParameters = PlaybackParameters.DEFAULT
private var sinkListener: AudioSink.Listener? = null
// --- Selection ---
@@ -255,6 +256,7 @@ internal class TrueHdCarrierSink(
// --- Persistent state: mirrored, so either delegate can be activated later ---
override fun setListener(listener: AudioSink.Listener) {
sinkListener = listener
defaultSink.setListener(listener)
carrierSink.setListener(listener)
}
@@ -270,15 +272,47 @@ internal class TrueHdCarrierSink(
}
override fun setPlaybackParameters(playbackParameters: PlaybackParameters) {
val wasUnitSpeed = this.playbackParameters.speed == 1f
val isUnitSpeed = playbackParameters.speed == 1f
this.playbackParameters = playbackParameters
defaultSink.setPlaybackParameters(playbackParameters)
carrierSink.setPlaybackParameters(playbackParameters)
// A bitstream cannot be resampled, and the carrier delegate has no processor chain to do it
// with, so it is never handed a speed nothing can apply.
carrierSink.setPlaybackParameters(
if (isUnitSpeed) playbackParameters else PlaybackParameters.DEFAULT
)
// Crossing 1x changes whether TrueHD may ride the carrier, but nothing re-asks on its own:
// the renderer only consults the sink when capabilities are invalidated. Rebuilding the track
// selector parameters is not enough either — DefaultTrackSelector skips invalidation when the
// rebuilt parameters compare equal. This is the path media3 itself uses for a route change,
// and it reaches onRendererCapabilitiesChanged, so the format is re-evaluated and TrueHD moves
// between the carrier and the decoder.
if (wasUnitSpeed != isUnitSpeed && (carrierActive || isUnitSpeed)) {
log?.invoke(
"info",
"audio",
"Playback speed ${if (isUnitSpeed) "returned to" else "left"} 1.0x; re-evaluating the TrueHD carrier"
)
sinkListener?.onAudioCapabilitiesChanged()
}
}
override fun getPlaybackParameters(): PlaybackParameters = active.getPlaybackParameters()
/** Whether TrueHD is currently riding the carrier. Read by the core when speed changes. */
val isCarrierActive: Boolean
get() = carrierActive
/**
* While the carrier is live the delegate is deliberately pinned to 1x, but the player polls this
* through the media clock and adopts whatever it reads. Reporting the delegate's value would push
* the pinned 1x back and silently undo the user's speed change, so the requested parameters are
* reported instead; the reselection triggered above then moves TrueHD onto the decoder, which
* really can apply them.
*/
override fun getPlaybackParameters(): PlaybackParameters = if (carrierActive) playbackParameters else active.getPlaybackParameters()
override fun setSkipSilenceEnabled(skipSilenceEnabled: Boolean) {
// Only reaches the normal sink: the carrier delegate has no processors to skip silence with,
// Only the normal sink: the carrier delegate is fed a fixed-rate bitstream,
// and dropping "silent" carrier bytes would break the frame cadence.
defaultSink.setSkipSilenceEnabled(skipSilenceEnabled)
}
@@ -100,6 +100,60 @@ class TrueHdCarrierSinkTest {
assertEquals(AudioSink.SINK_FORMAT_UNSUPPORTED, carrierSink.getFormatSupport(trueHdFormat()))
}
/**
* The real UI path: speed changes while the carrier is already live. Querying support after
* setting speed passes trivially; nothing re-asks the sink unless capabilities are invalidated,
* so without this the carrier keeps running and is handed a speed it cannot apply.
*/
@Test
fun leavingUnitSpeedMidPlaybackInvalidatesCapabilities() {
val carrier = FakeSink()
val carrierSink = sink(carrier = carrier)
val listener = RecordingSinkListener()
carrierSink.setListener(listener)
carrierSink.configure(trueHdFormat(), 0, null)
assertTrue("carrier should be live before the speed change", carrierSink.isCarrierActive)
carrierSink.setPlaybackParameters(PlaybackParameters(1.5f))
assertEquals("the renderer must be asked to re-evaluate", 1, listener.capabilityInvalidations)
assertEquals(
"the carrier delegate must never receive a speed it cannot apply",
1f,
carrier.lastPlaybackParameters.speed,
0f
)
assertEquals(AudioSink.SINK_FORMAT_UNSUPPORTED, carrierSink.getFormatSupport(trueHdFormat()))
}
/** Returning to 1x has to re-offer the carrier, or Atmos is lost until the next open. */
@Test
fun returningToUnitSpeedInvalidatesCapabilitiesAgain() {
val carrierSink = sink()
val listener = RecordingSinkListener()
carrierSink.setListener(listener)
carrierSink.configure(trueHdFormat(), 0, null)
carrierSink.setPlaybackParameters(PlaybackParameters(1.5f))
carrierSink.setPlaybackParameters(PlaybackParameters(1f))
assertEquals(2, listener.capabilityInvalidations)
assertEquals(AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY, carrierSink.getFormatSupport(trueHdFormat()))
}
/** A speed change that stays at 1x is not a transition and must not disturb selection. */
@Test
fun aNoOpSpeedChangeDoesNotInvalidateCapabilities() {
val carrierSink = sink()
val listener = RecordingSinkListener()
carrierSink.setListener(listener)
carrierSink.configure(trueHdFormat(), 0, null)
carrierSink.setPlaybackParameters(PlaybackParameters(1f))
assertEquals(0, listener.capabilityInvalidations)
}
/** Everything that is not TrueHD keeps going to the existing processed sink. */
@Test
fun otherFormatsAreLeftToTheNormalSink() {
@@ -231,6 +285,18 @@ class TrueHdCarrierSinkTest {
return field.get(sink) as FakeSink
}
private class RecordingSinkListener : AudioSink.Listener {
var capabilityInvalidations = 0
override fun onPositionDiscontinuity() = Unit
override fun onPositionAdvancing(playoutStartSystemTimeMs: Long) = Unit
override fun onUnderrun(bufferSize: Int, bufferSizeMs: Long, elapsedSinceLastFeedMs: Long) = Unit
override fun onSkipSilenceEnabledChanged(skipSilenceEnabled: Boolean) = Unit
override fun onAudioSinkError(audioSinkError: Exception) = Unit
override fun onAudioCapabilitiesChanged() {
capabilityInvalidations++
}
}
/** Minimal AudioSink that records what it was handed and can apply back pressure. */
private class FakeSink : AudioSink {
val written = ByteArrayOutputStream()
@@ -242,6 +308,7 @@ class TrueHdCarrierSinkTest {
var refuseNext: Boolean = false
var refuseEveryNth: Int = 0
var refusals: Int = 0
var lastPlaybackParameters: PlaybackParameters = PlaybackParameters.DEFAULT
private var offered = 0
override fun handleBuffer(buffer: ByteBuffer, presentationTimeUs: Long, encodedAccessUnitCount: Int): Boolean {
@@ -267,9 +334,15 @@ class TrueHdCarrierSinkTest {
override fun supportsFormat(format: Format) = formatSupport == AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY
override fun getFormatSupport(format: Format) = formatSupport
override fun setVolume(volume: Float) { lastVolume = volume }
override fun setAudioSessionId(audioSessionId: Int) { sessionId = audioSessionId }
override fun setSkipSilenceEnabled(skipSilenceEnabled: Boolean) { skipSilence = skipSilenceEnabled }
override fun setVolume(volume: Float) {
lastVolume = volume
}
override fun setAudioSessionId(audioSessionId: Int) {
sessionId = audioSessionId
}
override fun setSkipSilenceEnabled(skipSilenceEnabled: Boolean) {
skipSilence = skipSilenceEnabled
}
override fun getSkipSilenceEnabled() = skipSilence
override fun setListener(listener: AudioSink.Listener) = Unit
@@ -281,7 +354,9 @@ class TrueHdCarrierSinkTest {
override fun playToEndOfStream() = Unit
override fun isEnded() = false
override fun hasPendingData() = false
override fun setPlaybackParameters(playbackParameters: PlaybackParameters) = Unit
override fun setPlaybackParameters(playbackParameters: PlaybackParameters) {
lastPlaybackParameters = playbackParameters
}
override fun getPlaybackParameters(): PlaybackParameters = PlaybackParameters.DEFAULT
override fun setAudioAttributes(audioAttributes: AudioAttributes) = Unit
override fun getAudioAttributes(): AudioAttributes? = null