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:
Binary file not shown.
+195
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user