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
@@ -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)
}
}