playback: add dynamic ReplayGain mode

Use album gain for items whose queue entry references an album and track gain for playlists, singles and other mixed contexts, including shuffled album queues. Carry the resolved context on each media item so the audio thread does not need to inspect the queue.

Keep a track moved within an album as an album range so it stays in album context; moves out of playlists, artists, genres and other mixed contexts remain pinned song entries.
This commit is contained in:
austinried
2026-10-19 14:13:22 +00:00
parent 8348e048b5
commit 3f1d9b0d8d
18 changed files with 305 additions and 37 deletions
@@ -225,6 +225,9 @@ class UserPreferencesTest {
assertEquals(ReplayGainMode.Album, prefs.replayGainMode().first())
assertEquals(3.5f, prefs.replayGainPreampDb().first(), 0f)
prefs.setReplayGainMode(ReplayGainMode.Dynamic)
assertEquals(ReplayGainMode.Dynamic, prefs.replayGainMode().first())
prefs.setReplayGainPreampDb(99f)
assertEquals(MAX_REPLAY_GAIN_PREAMP_DB, prefs.replayGainPreampDb().first(), 0f)
@@ -485,6 +485,43 @@ class QueueRepositoryTest {
repository.move(repository.snapshot(), from = 2, to = 1)
assertEquals(listOf("s1", "s5", "s4", "s2", "s3"), resolveAll(repository.snapshot()))
val (entry, _) = repository.snapshot().locate(1)!!
assertEquals(QueueKind.Song, entry.kind)
}
@Test
fun movingAQueuedAlbumTrackKeepsItsAlbumEntry() =
runTest {
seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.addUpNext(snapshot, currentPosition = 0, entry = repository.albumEntry(1, "al2"), playNext = false)
repository.move(repository.snapshot(), from = 2, to = 1)
val (entry, _) = repository.snapshot().locate(1)!!
assertEquals(QueueKind.Album, entry.kind)
assertEquals("al2", entry.refId)
assertEquals(1L, entry.rangeStart)
assertEquals(1L, entry.rangeEnd)
}
@Test
fun movingAPlaylistTrackKeepsItAPinnedSong() =
runTest {
seedLibrary()
db.libraryDao().upsertPlaylistSongs(
listOf(
PlaylistSong(1, "p1", "s1", 0),
PlaylistSong(1, "p1", "s4", 1),
),
)
repository.snapshotAfter(listOf(repository.playlistEntry(1, "p1")))
repository.move(repository.snapshot(), from = 0, to = 1)
val (entry, _) = repository.snapshot().locate(1)!!
assertEquals(QueueKind.Song, entry.kind)
assertEquals("s1", entry.refId)
}
@Test
@@ -853,11 +890,30 @@ class QueueRepositoryTest {
repository.move(repository.snapshot(), from = 0, to = 2)
val (entry, _) = repository.snapshot().locate(2)!!
assertEquals(QueueKind.Song, entry.kind)
assertEquals("s1", entry.refId)
assertEquals(QueueKind.Album, entry.kind)
assertEquals("al1", entry.refId)
assertEquals(0L, entry.rangeStart)
assertEquals(0L, entry.rangeEnd)
assertEquals("First Album", repository.sourceName(entry.kind, entry.sourceId, entry.refId))
}
@Test
fun movingAnAlbumTrackBackCompactsTheAlbumEntry() =
runTest {
seedLibrary()
repository.replace(listOf(repository.albumEntry(1, "al1")))
repository.move(repository.snapshot(), from = 0, to = 2)
repository.move(repository.snapshot(), from = 2, to = 0)
val snapshot = repository.snapshot()
assertEquals(listOf("s1", "s2", "s3"), resolveAll(snapshot))
val entry = snapshot.entries.single().entry
assertEquals(QueueKind.Album, entry.kind)
assertEquals(0L, entry.rangeStart)
assertEquals(2L, entry.rangeEnd)
}
@Test
fun anAlbumSplitByRemovalIsStillLabeledByItsAlbum() =
runTest {
@@ -70,6 +70,20 @@ class MediaItemTest {
assertEquals(gain, item.replayGain())
}
@Test
fun theItemCarriesItsGainContext() {
val item =
queueMediaItem(
song.copy(albumContext = true),
local = null,
stream = "http://server/stream?s=s1",
transcode = false,
)
assertEquals(true, item.albumContext())
assertEquals(false, queueMediaItem(song, local = null, stream = "http://server/stream?s=s1", transcode = false).albumContext())
}
@Test
fun anItemWithoutGainReadsBackAsNoGain() {
val item = queueMediaItem(song, local = null, stream = "http://server/stream?s=s1", transcode = false)
@@ -479,6 +479,67 @@ class PlaybackControllerTest {
}
}
@Test
fun albumTracksAndQueuedSinglesCarryDifferentGainContexts() {
seedAlbum(3, sourceId = 1)
seedSong("x1", "al2")
controller.playAlbum(1, "al1", 0)
await { handle.items.any { it.id == "s1" } }
assertTrue(handle.items.first { it.id == "s1" }.albumContext)
controller.playNext(1, QueueKind.Song, "x1")
await { handle.items.any { it.id == "x1" } }
assertFalse(handle.items.first { it.id == "x1" }.albumContext)
}
@Test
fun queuedAlbumsCarryAlbumGain() {
seedAlbum(3, sourceId = 1)
seedSong("x1", "al2")
controller.playAlbum(1, "al1", 0)
await { handle.items.any { it.id == "s1" } }
controller.playNext(1, QueueKind.Album, "al2")
await { handle.items.any { it.id == "x1" } }
assertTrue(handle.items.first { it.id == "x1" }.albumContext)
}
@Test
fun movingAnAlbumTrackKeepsItsAlbumGainContext() {
seedAlbum(4, sourceId = 1)
controller.playAlbum(1, "al1", 0)
await { handle.items.any { it.id == "s1" } }
runBlocking { controller.move(0, 2) }
await { controller.state.value.position == 2L }
assertTrue(handle.items.first { it.id == "s1" }.albumContext)
val (entry, _) = runBlocking { queues.snapshot().locate(2)!! }
assertEquals(QueueKind.Album, entry.kind)
assertEquals("al1", entry.refId)
}
@Test
fun undoingARemovalRestoresTheTracksAlbumGainContext() {
seedAlbum(3, sourceId = 1)
controller.playAlbum(1, "al1", 0)
await { handle.items.any { it.id == "s2" } }
runBlocking { controller.removeAt(1) }
await { handle.items.none { it.id == "s2" } }
runBlocking { controller.undo() }
await { handle.items.any { it.id == "s2" } }
assertTrue(handle.items.first { it.id == "s2" }.albumContext)
}
@Test
fun tappingAContextTrackWhileAQueuedTrackPlaysKeepsTheQueue() {
seedAlbum(3, sourceId = 1)
@@ -1268,6 +1329,25 @@ class PlaybackControllerTest {
@Test
fun movingThePlayingContextTrackStillResolvesAnAlbumTitle() {
seedAlbum(3, sourceId = 1)
runBlocking {
db.libraryDao().upsertAlbums(
listOf(
Album(
sourceId = 1,
id = "al1",
artistId = "ar1",
name = "Album",
albumArtist = "Artist",
created = 0,
coverArt = null,
genre = null,
year = null,
starred = null,
songCount = 3,
),
),
)
}
controller.playAlbum(1, "al1", 1)
await {
@@ -35,6 +35,28 @@ class QueueItemTest {
)
}
@Test
fun carriesTheAlbumsGainContextWhenAsked() {
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,
)
val item = AlbumSongItem(song, coverArt = null).toQueueItem(albumContext = true)
assertEquals(true, item.albumContext)
}
@Test
fun aSongWithoutGainCarriesNoGain() {
val item =
@@ -38,6 +38,17 @@ class ReplayGainControllerTest {
assertEquals(1f, applied.last(), TOLERANCE)
}
@Test
fun dynamicModeFollowsTheItemsGainContext() {
controller.setSettings(ReplayGainSettings(ReplayGainMode.Dynamic, 0f))
controller.applyItem(item("a", trackGain = -6.0206f, albumGain = -1f))
assertEquals(0.5f, applied.last(), TOLERANCE)
controller.applyItem(item("b", trackGain = -1f, albumGain = -6.0206f, albumContext = true))
assertEquals(0.5f, applied.last(), TOLERANCE)
}
@Test
fun anItemWithoutGainPlaysAtUnity() {
controller.setSettings(ReplayGainSettings(ReplayGainMode.Track, 0f))
@@ -65,6 +76,8 @@ class ReplayGainControllerTest {
id: String,
trackGain: Float?,
trackPeak: Float? = null,
albumGain: Float? = null,
albumContext: Boolean = false,
) = queueMediaItem(
QueueItem(
id = id,
@@ -72,7 +85,8 @@ class ReplayGainControllerTest {
artist = null,
album = null,
coverArtId = null,
replayGain = ReplayGain(trackGain = trackGain, trackPeak = trackPeak),
replayGain = ReplayGain(trackGain = trackGain, albumGain = albumGain, trackPeak = trackPeak),
albumContext = albumContext,
),
local = null,
stream = "http://server/$id",
@@ -53,6 +53,40 @@ class ReplayGainTest {
)
}
@Test
fun dynamicModeAppliesTheAlbumGainInAnAlbumContext() {
assertEquals(
0.5f,
replayGainLinearGain(
ReplayGain(trackGain = -1f, albumGain = -6.0206f, trackPeak = 1f, albumPeak = 1f),
ReplayGainSettings(ReplayGainMode.Dynamic, 0f),
albumContext = true,
),
TOLERANCE,
)
}
@Test
fun dynamicModeAppliesTheTrackGainInAMixedContext() {
assertEquals(
0.5f,
replayGainLinearGain(
ReplayGain(trackGain = -6.0206f, albumGain = -1f, trackPeak = 1f, albumPeak = 1f),
ReplayGainSettings(ReplayGainMode.Dynamic, 0f),
albumContext = false,
),
TOLERANCE,
)
}
@Test
fun dynamicModeFallsBackInEitherContext() {
val settings = ReplayGainSettings(ReplayGainMode.Dynamic, 0f)
assertEquals(0.5f, replayGainLinearGain(ReplayGain(trackGain = -6.0206f), settings, albumContext = true), TOLERANCE)
assertEquals(0.5f, replayGainLinearGain(ReplayGain(albumGain = -6.0206f), settings, albumContext = false), TOLERANCE)
}
@Test
fun offAppliesUnityGain() {
assertEquals(
@@ -19,6 +19,7 @@ class ReplayGainControlsTest {
assertEquals("Off", replayGainModeLabel(resources, ReplayGainMode.Off))
assertEquals("Track gain", replayGainModeLabel(resources, ReplayGainMode.Track))
assertEquals("Album gain", replayGainModeLabel(resources, ReplayGainMode.Album))
assertEquals("Dynamic", replayGainModeLabel(resources, ReplayGainMode.Dynamic))
}
@Test