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.
This commit is contained in:
austinried
2026-10-16 13:07:07 +00:00
parent 3c0372cceb
commit 5f5ad85dff
7 changed files with 116 additions and 3 deletions
@@ -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 =
@@ -34,6 +34,7 @@ data class QueueItem(
val album: String?,
val coverArtId: String?,
val durationMs: Long? = null,
val replayGain: ReplayGain = ReplayGain(),
)
data class QueueContext(
@@ -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())
}
}
}
@@ -10,4 +10,5 @@ fun SongItem.toQueueItem() =
album = song.album,
coverArtId = coverArt,
durationMs = song.duration?.let { it * 1000 },
replayGain = song.replayGain(),
)
@@ -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,
@@ -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())
}
}
@@ -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)
}
}