diff --git a/android/app/src/androidTest/assets/ffmpeg/truehd_speed_repro.mka b/android/app/src/androidTest/assets/ffmpeg/truehd_speed_repro.mka new file mode 100644 index 00000000..6631c0c8 Binary files /dev/null and b/android/app/src/androidTest/assets/ffmpeg/truehd_speed_repro.mka differ diff --git a/android/app/src/androidTest/kotlin/com/edde746/plezy/exoplayer/TrueHdSpeedTransitionTest.kt b/android/app/src/androidTest/kotlin/com/edde746/plezy/exoplayer/TrueHdSpeedTransitionTest.kt new file mode 100644 index 00000000..3743fbee --- /dev/null +++ b/android/app/src/androidTest/kotlin/com/edde746/plezy/exoplayer/TrueHdSpeedTransitionTest.kt @@ -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(null) + val trackEncoding = AtomicReference(-1) + val player = AtomicReference(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): 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, + 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 + } +} diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/TrueHdCarrierSink.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/TrueHdCarrierSink.kt index 34df6028..585c4379 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/TrueHdCarrierSink.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/TrueHdCarrierSink.kt @@ -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) } diff --git a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/TrueHdCarrierSinkTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/TrueHdCarrierSinkTest.kt index 7a4309f3..91df4936 100644 --- a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/TrueHdCarrierSinkTest.kt +++ b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/TrueHdCarrierSinkTest.kt @@ -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