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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user