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
@@ -57,7 +57,7 @@ const val MAX_REPLAY_GAIN_PREAMP_DB = 15f
enum class SyncMode { Off, WifiOnly, Always }
enum class ReplayGainMode { Off, Track, Album }
enum class ReplayGainMode { Off, Track, Album, Dynamic }
data class ArtworkSeedValue(
val cacheKey: String,
@@ -164,6 +164,8 @@ data class QueueSnapshot(
}
fun locateContext(contextPlay: Long): Pair<QueueEntry, Long>? = locateIn(entries, flatContext(contextPlay) ?: return null)
fun isAlbumContext(position: Long): Boolean = locate(position)?.first?.kind == QueueKind.Album
}
private fun entryIndexAfter(
@@ -295,8 +297,8 @@ class QueueRepository(
?.takeIf { it.isNotBlank() }
}
// An explicit song entry in the middle of a queue is a reordered context track, so
// label it by its album rather than losing the context name.
// A song entry can be a single or a track reordered out of a playlist, artist or
// genre, so label it by its album rather than losing the context name.
QueueKind.Song -> {
library
.song(sourceId, refId)
@@ -581,9 +583,9 @@ class QueueRepository(
val toUpNext = snapshot.isUpNext(to)
if (fromUpNext && toUpNext) {
val song = itemAt(snapshot, from) ?: return false
val sourceId = snapshot.locate(from)?.first?.sourceId ?: return false
val located = snapshot.locate(from) ?: return false
val removed = removeEntry(snapshot.upNext, snapshot.upNextIndex(from))
val moved = ResolvedQueueEntry(songEntry(sourceId, song.song.id), 1L)
val moved = ResolvedQueueEntry(movedEntry(located.first, song.song.id, located.second), 1L)
writeUpNext(compact(insertEntry(removed, snapshot.upNextIndex(to), moved)).map { it.entry })
return true
}
@@ -597,7 +599,7 @@ class QueueRepository(
val located = snapshot.locateContext(playFrom) ?: return false
val song = itemAt(snapshot, from) ?: return false
val removed = removeEntry(snapshot.entries, playFrom)
val moved = ResolvedQueueEntry(songEntry(located.first.sourceId, song.song.id), 1L)
val moved = ResolvedQueueEntry(movedEntry(located.first, song.song.id, located.second), 1L)
val insertAt = playTo.coerceIn(0, removed.sumOf { it.length })
write(compact(insertEntry(removed, insertAt, moved)).map { it.entry })
adjustAnchorOnMove(playFrom, insertAt)
@@ -929,6 +931,18 @@ class QueueRepository(
return count?.coerceAtMost(available) ?: available
}
private fun movedEntry(
source: QueueEntry,
songId: String,
offsetInEntry: Long,
): QueueEntry =
if (source.kind == QueueKind.Album) {
val offset = source.offset + offsetInEntry
source.range(offset, offset)
} else {
songEntry(source.sourceId, songId)
}
private fun QueueEntry.range(
start: Long,
end: Long,
@@ -235,6 +235,7 @@ class Media3PlayerHandle(
?.takeIf { it.containsKey(EXTRA_DURATION_MS) }
?.getLong(EXTRA_DURATION_MS),
replayGain = mediaItem.replayGain(),
albumContext = mediaItem.albumContext(),
)
}
@@ -244,6 +245,7 @@ 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"
private const val EXTRA_ALBUM_CONTEXT = "albumContext"
internal fun MediaItem?.replayGain(): ReplayGain {
val extras = this?.mediaMetadata?.extras ?: return ReplayGain()
@@ -255,6 +257,8 @@ internal fun MediaItem?.replayGain(): ReplayGain {
)
}
internal fun MediaItem?.albumContext(): Boolean = this?.mediaMetadata?.extras?.getBoolean(EXTRA_ALBUM_CONTEXT) ?: false
private fun Bundle.putGain(
key: String,
value: Float?,
@@ -288,6 +292,7 @@ internal fun queueMediaItem(
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)
putBoolean(EXTRA_ALBUM_CONTEXT, item.albumContext)
},
).build()
val builder =
@@ -35,6 +35,7 @@ data class QueueItem(
val coverArtId: String?,
val durationMs: Long? = null,
val replayGain: ReplayGain = ReplayGain(),
val albumContext: Boolean = false,
)
data class QueueContext(
@@ -393,7 +394,7 @@ class PlaybackController(
val current = currentPosition() ?: return@withLock
val playing = player.playWhenReady
val wasUpNext = snapshot.isUpNext(current)
val removed = if (position == current) null else queueRepository.itemAt(snapshot, position)?.toQueueItem()
val removed = if (position == current) null else queueItemAt(snapshot, position)
queueRepository.removeAt(snapshot, position)
var updated = readSnapshot()
this.snapshot = updated
@@ -455,7 +456,7 @@ class PlaybackController(
if (snapshot.upNextSize > 0L || updated.upNextSize > 0L || snapshot.shuffled) {
queueRepository.setCursor(target)
if (position != current) {
rebuildWindow(queueRepository.window(updated, target, QUEUE_WINDOW_RADIUS), target)
rebuildWindow(queueRepository.window(updated, target, QUEUE_WINDOW_RADIUS), target, updated)
refresh(target)
} else {
loadWindow(target, autoplay = playing, startPositionMs = 0)
@@ -568,7 +569,7 @@ class PlaybackController(
}
else -> {
rebuildWindow(queueRepository.window(updated, target, QUEUE_WINDOW_RADIUS), target)
rebuildWindow(queueRepository.window(updated, target, QUEUE_WINDOW_RADIUS), target, updated)
}
}
shiftWindowLocked(target)
@@ -767,7 +768,7 @@ class PlaybackController(
if (wasUpNext) {
loadWindow(target, autoplay = true)
} else {
rebuildWindow(queueRepository.window(updated, target, QUEUE_WINDOW_RADIUS), target)
rebuildWindow(queueRepository.window(updated, target, QUEUE_WINDOW_RADIUS), target, updated)
refresh(target)
}
}
@@ -863,7 +864,7 @@ class PlaybackController(
layoutVersion++
queueRepository.setCursor(position)
windowJob?.cancel()
rebuildWindow(queueRepository.window(updated, position, QUEUE_WINDOW_RADIUS), position)
rebuildWindow(queueRepository.window(updated, position, QUEUE_WINDOW_RADIUS), position, updated)
refresh(position)
}
@@ -912,7 +913,11 @@ class PlaybackController(
if (anchor.changed) layoutVersion++
this@PlaybackController.snapshot = finalSnapshot
queueRepository.setCursor(anchor.position)
rebuildWindow(queueRepository.window(finalSnapshot, anchor.position, QUEUE_WINDOW_RADIUS), anchor.position)
rebuildWindow(
queueRepository.window(finalSnapshot, anchor.position, QUEUE_WINDOW_RADIUS),
anchor.position,
finalSnapshot,
)
endedHandled = false
refresh(anchor.position)
}
@@ -944,13 +949,13 @@ class PlaybackController(
if (!isOffline) {
return nextPosition(snapshot, at)
?.takeIf { it != at }
?.let { queueRepository.itemAt(snapshot, it)?.toQueueItem() }
?.let { queueItemAt(snapshot, it) }
}
downloads.awaitLoaded()
val target =
downloadedAtOrAfter(snapshot, at + 1)
?: if (repeatMode != RepeatMode.Off) downloadedAtOrAfter(snapshot, 0L) else null
return target?.takeIf { it != at }?.let { queueRepository.itemAt(snapshot, it)?.toQueueItem() }
return target?.takeIf { it != at }?.let { queueItemAt(snapshot, it) }
}
suspend fun previousItem(): QueueItem? {
@@ -967,13 +972,13 @@ class PlaybackController(
if (!isOffline) {
return previousPosition(snapshot, at)
?.takeIf { it != at }
?.let { queueRepository.itemAt(snapshot, it)?.toQueueItem() }
?.let { queueItemAt(snapshot, it) }
}
downloads.awaitLoaded()
val target =
downloadedAtOrBefore(snapshot, at - 1)
?: if (repeatMode == RepeatMode.All) downloadedAtOrBefore(snapshot, snapshot.size - 1) else null
return target?.takeIf { it != at }?.let { queueRepository.itemAt(snapshot, it)?.toQueueItem() }
return target?.takeIf { it != at }?.let { queueItemAt(snapshot, it) }
}
private fun play(
@@ -1132,7 +1137,7 @@ class PlaybackController(
lastPosition = actual
if (actual != requested) queueRepository.setCursor(actual)
updating = true
player.setWindow(window.map { it.item.toQueueItem() }, startIndex, startPositionMs)
player.setWindow(window.map { it.playerItem(snapshot) }, startIndex, startPositionMs)
updating = false
if (autoplay) {
player.prepare()
@@ -1172,7 +1177,7 @@ class PlaybackController(
window.forEachIndexed { index, entry ->
val local = downloads.localUri(entry.item.song.id) != null
if (local != (uris[index]?.startsWith(FILE_SCHEME) == true)) {
player.replaceItem(index, entry.item.toQueueItem())
player.replaceItem(index, entry.playerItem(snapshot))
replaced = true
}
}
@@ -1277,7 +1282,7 @@ class PlaybackController(
(currentId?.let { queueRepository.combinedIndexOf(snapshot, it) } ?: center)
.coerceIn(0, snapshot.size - 1)
queueRepository.setCursor(position)
rebuildWindow(queueRepository.window(snapshot, position, QUEUE_WINDOW_RADIUS), position)
rebuildWindow(queueRepository.window(snapshot, position, QUEUE_WINDOW_RADIUS), position, snapshot)
refresh(position)
return
}
@@ -1287,8 +1292,8 @@ class PlaybackController(
updating = true
try {
while (windowStart > desiredStart) {
val item = queueRepository.itemAt(snapshot, windowStart - 1) ?: break
player.addFirst(item.toQueueItem())
val item = queueItemAt(snapshot, windowStart - 1) ?: break
player.addFirst(item)
windowStart--
}
while (windowStart < desiredStart) {
@@ -1296,8 +1301,8 @@ class PlaybackController(
windowStart++
}
while (windowEnd < desiredEnd) {
val item = queueRepository.itemAt(snapshot, windowEnd + 1) ?: break
player.addLast(item.toQueueItem())
val item = queueItemAt(snapshot, windowEnd + 1) ?: break
player.addLast(item)
windowEnd++
}
while (windowEnd > desiredEnd) {
@@ -1313,6 +1318,7 @@ class PlaybackController(
private suspend fun rebuildWindow(
window: List<QueueWindowItem>,
position: Long,
snapshot: QueueSnapshot,
) {
val player = player ?: return
downloads.awaitLoaded()
@@ -1328,7 +1334,7 @@ class PlaybackController(
val current = ordered[index]
val wantsLocal = downloads.localUri(current.item.song.id) != null
val isLocal = player.itemUris().getOrNull(player.currentIndex)?.startsWith(FILE_SCHEME) == true
if (wantsLocal != isLocal) player.replaceItem(player.currentIndex, current.item.toQueueItem())
if (wantsLocal != isLocal) player.replaceItem(player.currentIndex, current.playerItem(snapshot))
windowPositions = LongArray(ordered.size) { ordered[it].position }
windowFiltered = offline
windowStart = ordered.first().position
@@ -1338,8 +1344,8 @@ class PlaybackController(
try {
repeat(player.currentIndex) { player.removeFirst() }
while (player.itemCount > 1) player.removeLast()
for (i in index - 1 downTo 0) player.addFirst(ordered[i].item.toQueueItem())
for (i in index + 1 until ordered.size) player.addLast(ordered[i].item.toQueueItem())
for (i in index - 1 downTo 0) player.addFirst(ordered[i].playerItem(snapshot))
for (i in index + 1 until ordered.size) player.addLast(ordered[i].playerItem(snapshot))
} finally {
updating = false
}
@@ -1414,7 +1420,7 @@ class PlaybackController(
if (target == current) {
// Keep the playing item in place and only add/remove its neighbours, so a downloaded
// track does not re-prepare (and audibly gap) when offline mode is toggled.
rebuildWindow(queueRepository.window(snapshot, target, QUEUE_WINDOW_RADIUS), target)
rebuildWindow(queueRepository.window(snapshot, target, QUEUE_WINDOW_RADIUS), target, snapshot)
refresh(target)
} else {
loadWindow(target, autoplay = player.playWhenReady, startPositionMs = 0)
@@ -1481,6 +1487,17 @@ class PlaybackController(
return snapshot
}
private fun QueueWindowItem.playerItem(snapshot: QueueSnapshot): QueueItem =
item.toQueueItem(albumContext = snapshot.isAlbumContext(position))
private suspend fun queueItemAt(
snapshot: QueueSnapshot,
position: Long,
): QueueItem? =
queueRepository
.itemAt(snapshot, position)
?.toQueueItem(albumContext = snapshot.isAlbumContext(position))
private fun contextAt(position: Long?): QueueContext? {
val snapshot = snapshot ?: return null
val play = position?.let { snapshot.anchorContextPlay(it) } ?: return null
@@ -2,7 +2,7 @@ package com.subtracks.playback
import com.subtracks.data.model.SongItem
fun SongItem.toQueueItem() =
fun SongItem.toQueueItem(albumContext: Boolean = false) =
QueueItem(
id = song.id,
title = song.title,
@@ -11,4 +11,5 @@ fun SongItem.toQueueItem() =
coverArtId = coverArt,
durationMs = song.duration?.let { it * 1000 },
replayGain = song.replayGain(),
albumContext = albumContext,
)
@@ -29,17 +29,20 @@ internal fun Song.replayGain(): ReplayGain =
internal fun replayGainLinearGain(
gain: ReplayGain,
settings: ReplayGainSettings,
albumContext: Boolean = false,
): Float {
if (settings.mode == ReplayGainMode.Off) return 1f
// gonic sends a peak of 0 when it only has album gain, so a non-positive or non-finite peak means
// "no peak data" and is ignored rather than treated as silence. A boost cannot be checked for
// headroom without a peak, so a peakless track is only ever attenuated.
val trackPeak = gain.trackPeak?.takeIf { it.isFinite() && it > 0f }
val albumPeak = gain.albumPeak?.takeIf { it.isFinite() && it > 0f }
val album = settings.mode == ReplayGainMode.Album || (settings.mode == ReplayGainMode.Dynamic && albumContext)
val (requestedDb, peak) =
when (settings.mode) {
ReplayGainMode.Off -> return 1f
ReplayGainMode.Track -> (gain.trackGain ?: gain.albumGain) to (trackPeak ?: albumPeak)
ReplayGainMode.Album -> (gain.albumGain ?: gain.trackGain) to (albumPeak ?: trackPeak)
if (album) {
(gain.albumGain ?: gain.trackGain) to (albumPeak ?: trackPeak)
} else {
(gain.trackGain ?: gain.albumGain) to (trackPeak ?: albumPeak)
}
val gainDb = requestedDb?.takeIf { it.isFinite() } ?: return 1f
val requested = gainDb + settings.preampDb
@@ -7,11 +7,13 @@ internal class ReplayGainController(
private val apply: (Float) -> Unit,
) {
private var track = ReplayGain()
private var albumContext = false
private var settings = ReplayGainSettings(ReplayGainMode.Track, 0f)
@Synchronized
fun applyItem(item: MediaItem) {
track = item.replayGain()
albumContext = item.albumContext()
applyGain()
}
@@ -22,6 +24,6 @@ internal class ReplayGainController(
}
private fun applyGain() {
apply(replayGainLinearGain(track, settings))
apply(replayGainLinearGain(track, settings, albumContext))
}
}
@@ -32,6 +32,7 @@ internal fun replayGainModeLabel(
ReplayGainMode.Off -> resources.getString(R.string.settings_replay_gain_mode_off)
ReplayGainMode.Track -> resources.getString(R.string.settings_replay_gain_mode_track)
ReplayGainMode.Album -> resources.getString(R.string.settings_replay_gain_mode_album)
ReplayGainMode.Dynamic -> resources.getString(R.string.settings_replay_gain_mode_dynamic)
}
internal fun replayGainPreampLabel(
+1
View File
@@ -83,6 +83,7 @@
<string name="settings_music_options_scrobble_description_on">Send now playing and play counts</string>
<string name="settings_music_options_scrobble_title">Scrobble to server</string>
<string name="settings_replay_gain_mode_album">Album gain</string>
<string name="settings_replay_gain_mode_dynamic">Dynamic</string>
<string name="settings_replay_gain_mode_off">Off</string>
<string name="settings_replay_gain_mode_track">Track gain</string>
<string name="settings_replay_gain_preamp_decrease">Lower preamp</string>
@@ -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
+1 -1
View File
@@ -98,7 +98,7 @@ Server writes — now-playing, scrobbles and stars — go through a `ServerActio
The media notification carries a star button for the playing track. `PlaybackService` adds a `com.subtracks.STAR` session command and a `CommandButton` to the `MediaSession`, and drives its filled/unfilled icon from the playing song's Room row. That path — `MediaSession.Callback`, `SessionCommand`, `CommandButton`, `SessionResult`/`SessionError` and `MediaSession.setMediaButtonPreferences` — is annotated `@UnstableApi` in Media3 1.11.1 and is therefore opted into explicitly: when bumping Media3, re-check those signatures and the notification's button resolution. The stable `setCustomLayout` is not a substitute here, because the notification manager only refreshes on `onMediaButtonPreferencesChanged`, so the icon would not update mid-playback.
ReplayGain is applied at the audio sink. The gain the server reports per song (`trackGain`/`albumGain` and their peaks; `baseGain` and `fallbackGain` are ignored) is synced into the song row, carried onto the media item, and applied by a `ReplayGainProcessor` in `DefaultAudioSink`'s processor chain, installed through a `RenderersFactory`. The gain is set from `AudioProcessor.flush(StreamMetadata)`, which Media3 1.11 populates with the playback `Timeline` and the current period id (`androidx/media#418`) and calls on every sink configuration — including a media-item transition at an unchanged format — so a new item's gain is in place before any of its buffers are processed. That timing is the point: `Player.Listener.onMediaItemTransition` arrives on the app thread after the playback thread has already rendered, which is too late. When bumping Media3, re-check that `flush` still carries the timeline and period, and that the chain is reached at all: float output and audio offload both bypass it, which is why float output stays off. The mode (track/album/off) and a preamp are DataStore preferences read by `PlaybackService`, and the anti-clip cap uses the peak when the server sends one: without a peak there is no headroom to check, so a peakless track is only ever attenuated.
ReplayGain is applied at the audio sink. The gain the server reports per song (`trackGain`/`albumGain` and their peaks; `baseGain` and `fallbackGain` are ignored) is synced into the song row, carried onto the media item, and applied by a `ReplayGainProcessor` in `DefaultAudioSink`'s processor chain, installed through a `RenderersFactory`. The gain is set from `AudioProcessor.flush(StreamMetadata)`, which Media3 1.11 populates with the playback `Timeline` and the current period id (`androidx/media#418`) and calls on every sink configuration — including a media-item transition at an unchanged format — so a new item's gain is in place before any of its buffers are processed. That timing is the point: `Player.Listener.onMediaItemTransition` arrives on the app thread after the playback thread has already rendered, which is too late. When bumping Media3, re-check that `flush` still carries the timeline and period, and that the chain is reached at all: float output and audio offload both bypass it, which is why float output stays off. The mode (track/album/dynamic/off) and a preamp are DataStore preferences read by `PlaybackService`, and the anti-clip cap uses the peak when the server sends one: without a peak there is no headroom to check, so a peakless track is only ever attenuated. Dynamic mode selects album gain for items whose queue entry references an album and track gain for playlists, singles and other mixed contexts, including shuffled album queues; the choice is carried on each media item because the audio thread cannot query the queue.
## Data model