From 5f5ad85dff4127747bdbfd7d60b7e7163c20756b Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Thu, 15 Oct 2026 06:54:30 +0000 Subject: [PATCH] playback: apply replay gain from the media item The gain was resolved by querying the library from the media item transition callback, so the buffers the player had already begun queueing for the new track could be scaled by the previous track's gain. A quiet track followed by a hot one could clip at the boundary for as long as that query took. Carry the gain on `QueueItem` so `queueMediaItem` puts it on the `MediaItem` extras, and apply it from the item that just became current, with no library round trip. That narrows the window rather than closing it: media3 delivers `onMediaItemTransition` on the app thread after the playback thread has already rendered for that iteration, so the very first buffers after a transition can still carry the previous item's gain. The gap is now one handler hop instead of a database query. The trade-off is staleness: if a sync changes a track's gain while that track is queued, the item keeps the old value until it is rebuilt. Applying a new gain mid-track is of marginal value anyway. --- .../subtracks/playback/Media3PlayerHandle.kt | 30 ++++++++++ .../subtracks/playback/PlaybackController.kt | 1 + .../com/subtracks/playback/PlaybackService.kt | 3 +- .../com/subtracks/playback/QueueItem.kt | 1 + .../com/subtracks/playback/ReplayGain.kt | 2 +- .../com/subtracks/playback/MediaItemTest.kt | 22 +++++++ .../com/subtracks/playback/QueueItemTest.kt | 60 +++++++++++++++++++ 7 files changed, 116 insertions(+), 3 deletions(-) create mode 100644 app/src/test/kotlin/com/subtracks/playback/QueueItemTest.kt diff --git a/app/src/main/kotlin/com/subtracks/playback/Media3PlayerHandle.kt b/app/src/main/kotlin/com/subtracks/playback/Media3PlayerHandle.kt index 0fe590f0..abc7b0b5 100644 --- a/app/src/main/kotlin/com/subtracks/playback/Media3PlayerHandle.kt +++ b/app/src/main/kotlin/com/subtracks/playback/Media3PlayerHandle.kt @@ -234,11 +234,37 @@ class Media3PlayerHandle( mediaItem.mediaMetadata.extras ?.takeIf { it.containsKey(EXTRA_DURATION_MS) } ?.getLong(EXTRA_DURATION_MS), + replayGain = mediaItem.replayGain(), ) } private const val EXTRA_COVER_ART_ID = "coverArtId" private const val EXTRA_DURATION_MS = "durationMs" +private const val EXTRA_REPLAY_GAIN_TRACK_GAIN = "replayGainTrackGain" +private const val EXTRA_REPLAY_GAIN_ALBUM_GAIN = "replayGainAlbumGain" +private const val EXTRA_REPLAY_GAIN_TRACK_PEAK = "replayGainTrackPeak" +private const val EXTRA_REPLAY_GAIN_ALBUM_PEAK = "replayGainAlbumPeak" + +// The gain rides on the MediaItem so PlaybackService can apply it the moment the item becomes +// current, rather than after an asynchronous round trip to the library. +internal fun MediaItem?.replayGain(): ReplayGain { + val extras = this?.mediaMetadata?.extras ?: return ReplayGain() + return ReplayGain( + trackGain = extras.gain(EXTRA_REPLAY_GAIN_TRACK_GAIN), + albumGain = extras.gain(EXTRA_REPLAY_GAIN_ALBUM_GAIN), + trackPeak = extras.gain(EXTRA_REPLAY_GAIN_TRACK_PEAK), + albumPeak = extras.gain(EXTRA_REPLAY_GAIN_ALBUM_PEAK), + ) +} + +private fun Bundle.putGain( + key: String, + value: Float?, +) { + if (value != null) putFloat(key, value) +} + +private fun Bundle.gain(key: String): Float? = if (containsKey(key)) getFloat(key) else null internal fun queueMediaItem( item: QueueItem, @@ -260,6 +286,10 @@ internal fun queueMediaItem( Bundle().apply { putString(EXTRA_COVER_ART_ID, item.coverArtId) item.durationMs?.let { putLong(EXTRA_DURATION_MS, it) } + putGain(EXTRA_REPLAY_GAIN_TRACK_GAIN, item.replayGain.trackGain) + putGain(EXTRA_REPLAY_GAIN_ALBUM_GAIN, item.replayGain.albumGain) + putGain(EXTRA_REPLAY_GAIN_TRACK_PEAK, item.replayGain.trackPeak) + putGain(EXTRA_REPLAY_GAIN_ALBUM_PEAK, item.replayGain.albumPeak) }, ).build() val builder = diff --git a/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt b/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt index 12b0ef98..e306f8ef 100644 --- a/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt +++ b/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt @@ -34,6 +34,7 @@ data class QueueItem( val album: String?, val coverArtId: String?, val durationMs: Long? = null, + val replayGain: ReplayGain = ReplayGain(), ) data class QueueContext( diff --git a/app/src/main/kotlin/com/subtracks/playback/PlaybackService.kt b/app/src/main/kotlin/com/subtracks/playback/PlaybackService.kt index ab0f6f00..12fcf237 100644 --- a/app/src/main/kotlin/com/subtracks/playback/PlaybackService.kt +++ b/app/src/main/kotlin/com/subtracks/playback/PlaybackService.kt @@ -140,6 +140,7 @@ class PlaybackService : MediaSessionService() { reason: Int, ) { observeCurrentSong(mediaItem?.mediaId) + replayGain.setTrack(mediaItem?.replayGain()) } }, ) @@ -181,7 +182,6 @@ class PlaybackService : MediaSessionService() { val library = library ?: return if (songId == null) { session?.setMediaButtonPreferences(emptyList()) - replayGain.setTrack(null) return } session?.setMediaButtonPreferences(listOf(starButton(false))) @@ -196,7 +196,6 @@ class PlaybackService : MediaSessionService() { if (currentSongId != songId) return@collect starred = song?.starred != null session?.setMediaButtonPreferences(listOf(starButton(starred))) - replayGain.setTrack(song?.replayGain()) } } } diff --git a/app/src/main/kotlin/com/subtracks/playback/QueueItem.kt b/app/src/main/kotlin/com/subtracks/playback/QueueItem.kt index 4691f83c..b72fba25 100644 --- a/app/src/main/kotlin/com/subtracks/playback/QueueItem.kt +++ b/app/src/main/kotlin/com/subtracks/playback/QueueItem.kt @@ -10,4 +10,5 @@ fun SongItem.toQueueItem() = album = song.album, coverArtId = coverArt, durationMs = song.duration?.let { it * 1000 }, + replayGain = song.replayGain(), ) diff --git a/app/src/main/kotlin/com/subtracks/playback/ReplayGain.kt b/app/src/main/kotlin/com/subtracks/playback/ReplayGain.kt index c8139f5b..cbb6d780 100644 --- a/app/src/main/kotlin/com/subtracks/playback/ReplayGain.kt +++ b/app/src/main/kotlin/com/subtracks/playback/ReplayGain.kt @@ -6,7 +6,7 @@ import kotlin.math.log10 import kotlin.math.min import kotlin.math.pow -internal data class ReplayGain( +data class ReplayGain( val trackGain: Float? = null, val albumGain: Float? = null, val trackPeak: Float? = null, diff --git a/app/src/test/kotlin/com/subtracks/playback/MediaItemTest.kt b/app/src/test/kotlin/com/subtracks/playback/MediaItemTest.kt index 585e059f..731bf948 100644 --- a/app/src/test/kotlin/com/subtracks/playback/MediaItemTest.kt +++ b/app/src/test/kotlin/com/subtracks/playback/MediaItemTest.kt @@ -54,4 +54,26 @@ class MediaItemTest { assertEquals("Song", item.mediaMetadata.title.toString()) assertEquals(CoverArtArtwork.uri("al1"), item.mediaMetadata.artworkUri) } + + @Test + fun theItemCarriesTheReplayGain() { + val gain = ReplayGain(trackGain = -3.2f, albumGain = -2.1f, trackPeak = 0.98f, albumPeak = 0.99f) + + val item = + queueMediaItem( + song.copy(replayGain = gain), + local = null, + stream = "http://server/stream?s=s1", + transcode = false, + ) + + assertEquals(gain, item.replayGain()) + } + + @Test + fun anItemWithoutGainReadsBackAsNoGain() { + val item = queueMediaItem(song, local = null, stream = "http://server/stream?s=s1", transcode = false) + + assertEquals(ReplayGain(), item.replayGain()) + } } diff --git a/app/src/test/kotlin/com/subtracks/playback/QueueItemTest.kt b/app/src/test/kotlin/com/subtracks/playback/QueueItemTest.kt new file mode 100644 index 00000000..7f2e96a0 --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/playback/QueueItemTest.kt @@ -0,0 +1,60 @@ +package com.subtracks.playback + +import com.subtracks.data.model.AlbumSongItem +import com.subtracks.data.model.Song +import org.junit.Assert.assertEquals +import org.junit.Test + +class QueueItemTest { + @Test + fun carriesTheSongsReplayGain() { + val song = + Song( + sourceId = 1, + id = "s1", + albumId = "al1", + artistId = "ar1", + title = "Song", + album = "Album", + artist = "Artist", + duration = 100, + track = 1, + disc = 1, + starred = null, + trackGain = -3.2f, + albumGain = -2.1f, + trackPeak = 0.98f, + albumPeak = 0.99f, + ) + + val item = AlbumSongItem(song, coverArt = "al1").toQueueItem() + + assertEquals( + ReplayGain(trackGain = -3.2f, albumGain = -2.1f, trackPeak = 0.98f, albumPeak = 0.99f), + item.replayGain, + ) + } + + @Test + fun aSongWithoutGainCarriesNoGain() { + val item = + AlbumSongItem( + Song( + sourceId = 1, + id = "s1", + albumId = "al1", + artistId = "ar1", + title = "Song", + album = "Album", + artist = "Artist", + duration = 100, + track = 1, + disc = 1, + starred = null, + ), + coverArt = null, + ).toQueueItem() + + assertEquals(ReplayGain(), item.replayGain) + } +}