diff --git a/app/src/main/kotlin/com/subtracks/playback/PlaybackService.kt b/app/src/main/kotlin/com/subtracks/playback/PlaybackService.kt index 66f02cb0..d808a4e8 100644 --- a/app/src/main/kotlin/com/subtracks/playback/PlaybackService.kt +++ b/app/src/main/kotlin/com/subtracks/playback/PlaybackService.kt @@ -62,8 +62,8 @@ class PlaybackService : MediaSessionService() { private var starred = false private var songJob: Job? = null - private val gainProcessor = ReplayGainProcessor() - private val replayGain = ReplayGainController { gainProcessor.setLinearGain(it) } + private val gainProcessor: ReplayGainProcessor = ReplayGainProcessor { replayGain.applyItem(it) } + private val replayGain: ReplayGainController = ReplayGainController { gainProcessor.setLinearGain(it) } private val starCallback = object : MediaSession.Callback { diff --git a/app/src/main/kotlin/com/subtracks/playback/ReplayGainController.kt b/app/src/main/kotlin/com/subtracks/playback/ReplayGainController.kt index 8e8f185a..6d254a38 100644 --- a/app/src/main/kotlin/com/subtracks/playback/ReplayGainController.kt +++ b/app/src/main/kotlin/com/subtracks/playback/ReplayGainController.kt @@ -5,7 +5,12 @@ import com.subtracks.data.prefs.ReplayGainMode internal class ReplayGainController( private val apply: (Float) -> Unit, ) { + // Written from the playback thread by ReplayGainProcessor.onFlush and from the app thread by + // the media item transition fallback. + @Volatile private var track = ReplayGain() + + @Volatile private var settings = ReplayGainSettings(ReplayGainMode.Track, 0f) fun setTrack(value: ReplayGain?) { diff --git a/app/src/main/kotlin/com/subtracks/playback/ReplayGainProcessor.kt b/app/src/main/kotlin/com/subtracks/playback/ReplayGainProcessor.kt index 6ac8e415..111a1930 100644 --- a/app/src/main/kotlin/com/subtracks/playback/ReplayGainProcessor.kt +++ b/app/src/main/kotlin/com/subtracks/playback/ReplayGainProcessor.kt @@ -2,14 +2,19 @@ package com.subtracks.playback import androidx.annotation.OptIn import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.Timeline import androidx.media3.common.audio.AudioProcessor.AudioFormat +import androidx.media3.common.audio.AudioProcessor.StreamMetadata import androidx.media3.common.audio.BaseAudioProcessor import androidx.media3.common.util.UnstableApi import java.nio.ByteBuffer import kotlin.math.roundToInt @OptIn(UnstableApi::class) -internal class ReplayGainProcessor : BaseAudioProcessor() { +internal class ReplayGainProcessor( + private val onItem: (MediaItem?) -> Unit = {}, +) : BaseAudioProcessor() { @Volatile private var linearGain = 1f @@ -19,6 +24,15 @@ internal class ReplayGainProcessor : BaseAudioProcessor() { override fun onConfigure(inputAudioFormat: AudioFormat): AudioFormat = inputAudioFormat + // DefaultAudioSink configures and flushes the processors at every media item transition, even + // when the format is unchanged, and hands over the timeline and the period being flushed. That + // is early enough to set the new item's gain before any of its buffers are processed, unlike + // Player.Listener.onMediaItemTransition, which the app thread receives afterwards. + // See androidx/media#418. + override fun onFlush(streamMetadata: StreamMetadata) { + onItem(streamMetadata.currentItem()) + } + override fun queueInput(inputBuffer: ByteBuffer) { val output = replaceOutputBuffer(inputBuffer.remaining()) val gain = linearGain.takeIf { it.isFinite() } ?: 1f @@ -46,3 +60,12 @@ internal class ReplayGainProcessor : BaseAudioProcessor() { const val MAX_SAMPLE = 32767 } } + +@OptIn(UnstableApi::class) +internal fun StreamMetadata.currentItem(): MediaItem? { + val uid = periodUid ?: return null + val index = timeline.getIndexOfPeriod(uid) + if (index == C.INDEX_UNSET) return null + val period = timeline.getPeriod(index, Timeline.Period()) + return timeline.getWindow(period.windowIndex, Timeline.Window()).mediaItem +} diff --git a/app/src/test/kotlin/com/subtracks/playback/ReplayGainProcessorTest.kt b/app/src/test/kotlin/com/subtracks/playback/ReplayGainProcessorTest.kt index 39703c58..e752ed82 100644 --- a/app/src/test/kotlin/com/subtracks/playback/ReplayGainProcessorTest.kt +++ b/app/src/test/kotlin/com/subtracks/playback/ReplayGainProcessorTest.kt @@ -2,15 +2,21 @@ package com.subtracks.playback import androidx.annotation.OptIn import androidx.media3.common.C +import androidx.media3.common.MediaItem import androidx.media3.common.audio.AudioProcessor.AudioFormat import androidx.media3.common.audio.AudioProcessor.StreamMetadata import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.source.SinglePeriodTimeline +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.subtracks.data.prefs.ReplayGainMode import org.junit.Assert.assertEquals import org.junit.Test +import org.junit.runner.RunWith import java.nio.ByteBuffer import java.nio.ByteOrder @OptIn(UnstableApi::class) +@RunWith(AndroidJUnit4::class) class ReplayGainProcessorTest { @Test fun scalesSixteenBitSamples() { @@ -85,6 +91,42 @@ class ReplayGainProcessorTest { assertEquals(true, processor.isActive()) } + @Test + fun aFlushReportsTheItemBeingFlushed() { + val reported = mutableListOf() + val item = item("a", trackGain = -6.0206f) + + ReplayGainProcessor { reported += it }.flush(streamMetadata(item)) + + assertEquals(listOf(item), reported) + } + + @Test + fun aFlushWithoutAPeriodReportsNoItem() { + val reported = mutableListOf() + + ReplayGainProcessor { reported += it }.flush(StreamMetadata.DEFAULT) + + assertEquals(listOf(null), reported) + } + + @Test + fun aFlushMovesTheGainToTheNextItem() { + lateinit var processor: ReplayGainProcessor + val controller = ReplayGainController { processor.setLinearGain(it) } + processor = ReplayGainProcessor { controller.applyItem(it) } + controller.setSettings(ReplayGainSettings(ReplayGainMode.Track, 0f)) + processor.configure(AudioFormat(SAMPLE_RATE, CHANNELS, C.ENCODING_PCM_16BIT)) + + processor.flush(streamMetadata(item("a", trackGain = -6.0206f))) + processor.queueInput(sixteenBit(1000)) + assertEquals(listOf(500), readShorts(processor.getOutput())) + + processor.flush(streamMetadata(item("b", trackGain = 6.0206f))) + processor.queueInput(sixteenBit(1000)) + assertEquals(listOf(2000), readShorts(processor.getOutput())) + } + private fun processor( gain: Float, encoding: Int = C.ENCODING_PCM_16BIT, @@ -95,6 +137,33 @@ class ReplayGainProcessorTest { flush(StreamMetadata.DEFAULT) } + private fun item( + id: String, + trackGain: Float, + ): MediaItem = + queueMediaItem( + QueueItem( + id = id, + title = id, + artist = null, + album = null, + coverArtId = null, + replayGain = ReplayGain(trackGain = trackGain), + ), + local = null, + stream = "http://server/$id", + transcode = false, + ) + + private fun streamMetadata(item: MediaItem): StreamMetadata { + val timeline = SinglePeriodTimeline(DURATION_US, false, false, false, null, item) + return StreamMetadata + .Builder() + .setTimeline(timeline) + .setPeriodUid(timeline.getUidOfPeriod(0)) + .build() + } + private fun sixteenBit(vararg samples: Int): ByteBuffer = ByteBuffer.allocate(samples.size * 2).order(ByteOrder.nativeOrder()).apply { samples.forEach { putShort(it.toShort()) } @@ -125,5 +194,6 @@ class ReplayGainProcessorTest { private companion object { const val SAMPLE_RATE = 44_100 const val CHANNELS = 2 + const val DURATION_US = 1_000_000L } }