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
}
}