mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 23:21:59 +01:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user