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(