feat(android): level playback with a gain processor in the audio sink (M464 #5000)
release / go (push) Successful in 1m43s
release / web (push) Successful in 1m27s
release / govulncheck (push) Successful in 35s
release / integration (push) Successful in 5m16s
release / android (push) Failing after 3m52s
release / Build signed APK (releases and dev) (push) Failing after 3m20s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped

Media3 1.10.1 -> 1.11.0, the version Renovate proposes; 1.11 flushes the
sink's audio processors at every item boundary with the playlist timeline
and the new item's period. GainAudioProcessor uses that to find the track
and its play-order neighbours (auto mode's album rule) and applies the
gain from the first sample, gapless transitions included, with a -1 dBFS
peak limiter in limiter mode and a full-scale clamp otherwise.

Gains come from the library cache first (sync now carries track and album
ReplayGain values; Room v10 adds the columns and rewinds the sync cursor
so an existing cache re-pulls them), then GET /api/tracks/replay-gain,
then none. The player service refreshes the leveling preference at start.

Web: a same-album neighbour without a track number no longer counts as
in-order album play, matching Android.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 19:10:35 -04:00
co-authored by Claude Opus 5.5
parent 38290bf8f9
commit 1013c283da
22 changed files with 902 additions and 17 deletions
@@ -0,0 +1,15 @@
package com.fabledsword.minstrel.api.endpoints
import com.fabledsword.minstrel.models.wire.ReplayGainResponseWire
import retrofit2.http.GET
import retrofit2.http.Query
/** The player's loudness lookup (#4997), kept apart from the browse surface in [LibraryApi]. */
interface ReplayGainApi {
/**
* ReplayGain values for up to 200 comma-separated track ids. An id
* missing from `items` has not been measured yet.
*/
@GET("api/tracks/replay-gain")
suspend fun getReplayGain(@Query("ids") ids: String): ReplayGainResponseWire
}
@@ -67,6 +67,10 @@ import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
AuthSessionEntity::class, AuthSessionEntity::class,
DiagnosticEventEntity::class, DiagnosticEventEntity::class,
], ],
// v10: + cached_tracks.trackGain/trackPeak and cached_albums.albumGain/
// albumPeak, the ReplayGain values the player levels by (M464 #5000).
// MIGRATION_9_10 also rewinds the sync cursor, so the next sync re-sends
// every row and an existing cache gains its values.
// v9: + auth_session.normalizationJson, the loudness-normalization // v9: + auth_session.normalizationJson, the loudness-normalization
// preference (M464 #4998). The first schema step with an explicit // preference (M464 #4998). The first schema step with an explicit
// Migration (MIGRATION_8_9): a destructive rebuild would also wipe this // Migration (MIGRATION_8_9): a destructive rebuild would also wipe this
@@ -78,7 +82,7 @@ import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
// on auth_session. Pre-v1 destructive fallback rebuilds on mismatch — // on auth_session. Pre-v1 destructive fallback rebuilds on mismatch —
// which is exactly right here: the next sync refills every row with the // which is exactly right here: the next sync refills every row with the
// new column populated, so there is nothing to migrate by hand. // new column populated, so there is nothing to migrate by hand.
version = 9, version = 10,
exportSchema = true, exportSchema = true,
) )
@TypeConverters(MinstrelTypeConverters::class) @TypeConverters(MinstrelTypeConverters::class)
@@ -106,3 +110,18 @@ val MIGRATION_8_9: Migration = object : Migration(8, 9) {
db.execSQL("ALTER TABLE auth_session ADD COLUMN normalizationJson TEXT") db.execSQL("ALTER TABLE auth_session ADD COLUMN normalizationJson TEXT")
} }
} }
/**
* v9 → v10: the gain columns (#5000). Rows synced before this carry no gains,
* and the sync is incremental, so it would never re-send them: the cursor goes
* back to 0 and the next sync is a full one, upserting every row in place.
*/
val MIGRATION_9_10: Migration = object : Migration(9, 10) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE cached_tracks ADD COLUMN trackGain REAL")
db.execSQL("ALTER TABLE cached_tracks ADD COLUMN trackPeak REAL")
db.execSQL("ALTER TABLE cached_albums ADD COLUMN albumGain REAL")
db.execSQL("ALTER TABLE cached_albums ADD COLUMN albumPeak REAL")
db.execSQL("UPDATE sync_metadata SET cursor = 0")
}
}
@@ -37,7 +37,7 @@ object DatabaseModule {
// launch, so users lose only the unsynced mutation queue // launch, so users lose only the unsynced mutation queue
// (acceptable while we're iterating). Replace with explicit // (acceptable while we're iterating). Replace with explicit
// Migration entries before the first tagged release. // Migration entries before the first tagged release.
.addMigrations(MIGRATION_8_9) .addMigrations(MIGRATION_8_9, MIGRATION_9_10)
.fallbackToDestructiveMigration(dropAllTables = true) .fallbackToDestructiveMigration(dropAllTables = true)
.build() .build()
@@ -38,6 +38,27 @@ interface CachedTrackDao {
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertAll(rows: List<CachedTrackEntity>) suspend fun upsertAll(rows: List<CachedTrackEntity>)
/**
* ReplayGain values for [ids] (M464 #5000): the track's own from its row,
* the album's from its album row. A track not in the cache has no row.
*/
@Query(
"SELECT t.id AS id, t.trackGain AS trackGain, t.trackPeak AS trackPeak, " +
"a.albumGain AS albumGain, a.albumPeak AS albumPeak " +
"FROM cached_tracks t LEFT JOIN cached_albums a ON a.id = t.albumId " +
"WHERE t.id IN (:ids)",
)
suspend fun replayGains(ids: List<String>): List<CachedReplayGain>
@Query("DELETE FROM cached_tracks WHERE id IN (:ids)") @Query("DELETE FROM cached_tracks WHERE id IN (:ids)")
suspend fun deleteByIds(ids: List<String>) suspend fun deleteByIds(ids: List<String>)
} }
/** One row of [CachedTrackDao.replayGains]. */
data class CachedReplayGain(
val id: String,
val trackGain: Float?,
val trackPeak: Float?,
val albumGain: Float?,
val albumPeak: Float?,
)
@@ -18,5 +18,8 @@ data class CachedAlbumEntity(
val releaseDate: String? = null, val releaseDate: String? = null,
val coverPath: String? = null, val coverPath: String? = null,
val mbid: String? = null, val mbid: String? = null,
// ReplayGain 2.0 album values (M464); null until every track is measured.
val albumGain: Float? = null,
val albumPeak: Float? = null,
val fetchedAt: Instant = Clock.System.now(), val fetchedAt: Instant = Clock.System.now(),
) )
@@ -26,5 +26,9 @@ data class CachedTrackEntity(
val fileFormat: String? = null, val fileFormat: String? = null,
val genre: String? = null, val genre: String? = null,
val missing: Boolean = false, val missing: Boolean = false,
// ReplayGain 2.0 track values (M464), kept so cached audio levels
// offline. Null until the server has measured the track.
val trackGain: Float? = null,
val trackPeak: Float? = null,
val fetchedAt: Instant = Clock.System.now(), val fetchedAt: Instant = Clock.System.now(),
) )
@@ -206,6 +206,8 @@ private fun SyncAlbumWire.toEntity(): CachedAlbumEntity = CachedAlbumEntity(
releaseDate = releaseDate, releaseDate = releaseDate,
coverPath = coverArtPath, coverPath = coverArtPath,
mbid = mbid, mbid = mbid,
albumGain = albumGain,
albumPeak = albumPeak,
) )
private fun SyncTrackWire.toEntity(): CachedTrackEntity = CachedTrackEntity( private fun SyncTrackWire.toEntity(): CachedTrackEntity = CachedTrackEntity(
@@ -220,4 +222,6 @@ private fun SyncTrackWire.toEntity(): CachedTrackEntity = CachedTrackEntity(
fileFormat = fileFormat, fileFormat = fileFormat,
genre = genre, genre = genre,
missing = missing, missing = missing,
trackGain = trackGain,
trackPeak = trackPeak,
) )
@@ -0,0 +1,18 @@
package com.fabledsword.minstrel.models.wire
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** One track's entry in `GET /api/tracks/replay-gain` (#4997). Decode-only. */
@Serializable
data class ReplayGainWire(
@SerialName("track_gain") val trackGain: Float? = null,
@SerialName("track_peak") val trackPeak: Float? = null,
@SerialName("album_gain") val albumGain: Float? = null,
@SerialName("album_peak") val albumPeak: Float? = null,
)
@Serializable
data class ReplayGainResponseWire(
val items: Map<String, ReplayGainWire> = emptyMap(),
)
@@ -28,6 +28,10 @@ data class SyncAlbumWire(
@SerialName("release_date") val releaseDate: String? = null, @SerialName("release_date") val releaseDate: String? = null,
@SerialName("cover_art_path") val coverArtPath: String? = null, @SerialName("cover_art_path") val coverArtPath: String? = null,
val mbid: String? = null, val mbid: String? = null,
// The album's ReplayGain 2.0 values (#4997): dB to -18 LUFS and a linear
// peak. Null until every track on the album is measured.
@SerialName("album_gain") val albumGain: Float? = null,
@SerialName("album_peak") val albumPeak: Float? = null,
) )
@Serializable @Serializable
@@ -51,6 +55,9 @@ data class SyncTrackWire(
// its tracks stay playable, which is the correct reading of "this server // its tracks stay playable, which is the correct reading of "this server
// has nothing to say about missing files". // has nothing to say about missing files".
val missing: Boolean = false, val missing: Boolean = false,
// The track's ReplayGain 2.0 values (#4997); null until it is measured.
@SerialName("track_gain") val trackGain: Float? = null,
@SerialName("track_peak") val trackPeak: Float? = null,
) )
/** /**
@@ -12,8 +12,10 @@ import androidx.media3.session.SessionCommand
import com.fabledsword.minstrel.MainActivity import com.fabledsword.minstrel.MainActivity
import com.fabledsword.minstrel.likes.data.LikesRepository import com.fabledsword.minstrel.likes.data.LikesRepository
import com.fabledsword.minstrel.likes.data.LikesRepository.Companion.ENTITY_TRACK import com.fabledsword.minstrel.likes.data.LikesRepository.Companion.ENTITY_TRACK
import com.fabledsword.minstrel.settings.data.NormalizationRepository
import com.google.common.collect.ImmutableList import com.google.common.collect.ImmutableList
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -25,6 +27,7 @@ import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
/** /**
@@ -66,6 +69,8 @@ class MinstrelPlayerService : MediaSessionService() {
@Inject lateinit var likesRepository: LikesRepository @Inject lateinit var likesRepository: LikesRepository
@Inject lateinit var normalizationRepository: NormalizationRepository
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private var mediaSession: MediaSession? = null private var mediaSession: MediaSession? = null
@@ -82,6 +87,21 @@ class MinstrelPlayerService : MediaSessionService() {
.build() .build()
mediaSession = session mediaSession = session
serviceScope.launch { observeLikeState(session, player) } serviceScope.launch { observeLikeState(session, player) }
serviceScope.launch { refreshNormalization() }
}
// Takes up a leveling preference changed on another device (M464 #5000).
// Offline, the device's copy stands, which is the one playback reads.
private suspend fun refreshNormalization() {
try {
normalizationRepository.refresh()
} catch (e: CancellationException) {
throw e
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
Timber.d(e, "normalization refresh skipped")
}
} }
/** /**
@@ -14,6 +14,8 @@ import androidx.media3.session.MediaController
import androidx.media3.session.SessionToken import androidx.media3.session.SessionToken
import com.fabledsword.minstrel.di.ApplicationScope import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.models.TrackRef import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.player.gain.GainAudioProcessor
import com.fabledsword.minstrel.player.gain.ReplayGainStore
import com.fabledsword.minstrel.playlists.data.PlaylistsRepository import com.fabledsword.minstrel.playlists.data.PlaylistsRepository
import com.fabledsword.minstrel.playlists.data.toPlayableTrackRefs import com.fabledsword.minstrel.playlists.data.toPlayableTrackRefs
import com.fabledsword.minstrel.shared.resolveServerUrl import com.fabledsword.minstrel.shared.resolveServerUrl
@@ -69,6 +71,7 @@ class PlayerController @Inject constructor(
private val playerFactory: PlayerFactory, private val playerFactory: PlayerFactory,
private val activeUpnpHolder: com.fabledsword.minstrel.player.output.ActiveUpnpHolder, private val activeUpnpHolder: com.fabledsword.minstrel.player.output.ActiveUpnpHolder,
private val remoteState: RemotePlayerState, private val remoteState: RemotePlayerState,
private val replayGains: ReplayGainStore,
) { ) {
/** /**
@@ -277,6 +280,11 @@ class PlayerController @Inject constructor(
queueRefs = playable.tracks queueRefs = playable.tracks
val items = playable.tracks.map { it.toMediaItem(source) } val items = playable.tracks.map { it.toMediaItem(source) }
val startIndex = playable.initialIndex val startIndex = playable.initialIndex
// Gains for the first tracks, so the first one levels from its first
// sample rather than ramping in once the lookup lands.
replayGains.request(
playable.tracks.drop(startIndex).take(GAIN_PREFETCH).map { it.id },
)
// Drift #562 cold-boot resume calls this from a non-Main suspend // Drift #562 cold-boot resume calls this from a non-Main suspend
// context after awaitReady() unblocks (ResumeController launches // context after awaitReady() unblocks (ResumeController launches
// on Dispatchers.Default by the time it reaches us). MediaController // on Dispatchers.Default by the time it reaches us). MediaController
@@ -792,7 +800,13 @@ class PlayerController @Inject constructor(
// scrubber a real total even when the wrapped ExoPlayer is // scrubber a real total even when the wrapped ExoPlayer is
// paused under UPnP (it never probes a duration in that state). // paused under UPnP (it never probes a duration in that state).
if (durationSec > 0) setDurationMs(durationSec.toLong() * MS_PER_SECOND) if (durationSec > 0) setDurationMs(durationSec.toLong() * MS_PER_SECOND)
if (source != null) setExtras(sourceExtras(source)) // Album and track position let the gain processor tell an
// album played in order from a mix (M464 #5000).
trackNumber?.let { setTrackNumber(it) }
discNumber?.let { setDiscNumber(it) }
val extras = GainAudioProcessor.albumExtras(albumId)
if (source != null) extras.putAll(sourceExtras(source))
setExtras(extras)
// Point the notification / lock-screen art at the SAME album // Point the notification / lock-screen art at the SAME album
// cover the in-app surfaces use (TrackRef.coverUrl -> // cover the in-app surfaces use (TrackRef.coverUrl ->
// /api/albums/{id}/cover). Without this, Media3 falls back to // /api/albums/{id}/cover). Without this, Media3 falls back to
@@ -860,6 +874,7 @@ class PlayerController @Inject constructor(
const val MINSTREL_SOURCE_KEY: String = "minstrel_source" const val MINSTREL_SOURCE_KEY: String = "minstrel_source"
private const val MS_PER_SECOND = 1_000L private const val MS_PER_SECOND = 1_000L
private const val MAX_INTERPOLATION_DRIFT_MS = 5_000L private const val MAX_INTERPOLATION_DRIFT_MS = 5_000L
private const val GAIN_PREFETCH = 20
} }
} }
@@ -4,6 +4,7 @@ import android.content.Context
import androidx.media3.common.AudioAttributes import androidx.media3.common.AudioAttributes
import androidx.media3.common.C import androidx.media3.common.C
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.audio.AudioProcessor
import androidx.media3.common.util.BitmapLoader import androidx.media3.common.util.BitmapLoader
import androidx.media3.database.StandaloneDatabaseProvider import androidx.media3.database.StandaloneDatabaseProvider
import androidx.media3.datasource.DataSourceBitmapLoader import androidx.media3.datasource.DataSourceBitmapLoader
@@ -12,10 +13,16 @@ import androidx.media3.datasource.cache.CacheDataSource
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
import androidx.media3.datasource.cache.SimpleCache import androidx.media3.datasource.cache.SimpleCache
import androidx.media3.datasource.okhttp.OkHttpDataSource import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.audio.AudioSink
import androidx.media3.exoplayer.audio.DefaultAudioSink
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.session.CacheBitmapLoader import androidx.media3.session.CacheBitmapLoader
import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.cache.audiocache.CacheConfig import com.fabledsword.minstrel.cache.audiocache.CacheConfig
import com.fabledsword.minstrel.player.gain.GainAudioProcessor
import com.fabledsword.minstrel.player.gain.ReplayGainStore
import com.fabledsword.minstrel.player.output.ActiveUpnpHolder import com.fabledsword.minstrel.player.output.ActiveUpnpHolder
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.BufferOverflow
@@ -56,6 +63,8 @@ class PlayerFactory @Inject constructor(
private val activeUpnpHolder: ActiveUpnpHolder, private val activeUpnpHolder: ActiveUpnpHolder,
private val remoteState: RemotePlayerState, private val remoteState: RemotePlayerState,
private val serverHealth: com.fabledsword.minstrel.connectivity.NetworkStatusController, private val serverHealth: com.fabledsword.minstrel.connectivity.NetworkStatusController,
private val authStore: AuthStore,
private val replayGains: ReplayGainStore,
) { ) {
private val cacheDir: File = File(context.cacheDir, "audio_cache").apply { mkdirs() } private val cacheDir: File = File(context.cacheDir, "audio_cache").apply { mkdirs() }
@@ -142,7 +151,28 @@ class PlayerFactory @Inject constructor(
val mediaSourceFactory = DefaultMediaSourceFactory(context) val mediaSourceFactory = DefaultMediaSourceFactory(context)
.setDataSourceFactory(cacheDataSource) .setDataSourceFactory(cacheDataSource)
return ExoPlayer.Builder(context) // Loudness normalization (M464 #5000) runs inside the audio sink so
// each track's gain starts on its first sample. Audio offload would
// bypass the sink's processors; ExoPlayer leaves it off unless asked
// (TrackSelectionParameters.audioOffloadPreferences), and nothing here
// asks, so leveling always applies.
val gainProcessor = GainAudioProcessor(
prefs = { authStore.normalization.value },
store = replayGains,
)
val renderersFactory = object : DefaultRenderersFactory(context) {
override fun buildAudioSink(
context: Context,
enableFloatOutput: Boolean,
enableAudioOutputPlaybackParams: Boolean,
): AudioSink = DefaultAudioSink.Builder(context)
.setEnableFloatOutput(enableFloatOutput)
.setEnableAudioOutputPlaybackParameters(enableAudioOutputPlaybackParams)
.setAudioProcessors(arrayOf<AudioProcessor>(gainProcessor))
.build()
}
val exo = ExoPlayer.Builder(context, renderersFactory)
.setMediaSourceFactory(mediaSourceFactory) .setMediaSourceFactory(mediaSourceFactory)
.setAudioAttributes( .setAudioAttributes(
AudioAttributes.Builder() AudioAttributes.Builder()
@@ -153,6 +183,17 @@ class PlayerFactory @Inject constructor(
) )
.setHandleAudioBecomingNoisy(true) .setHandleAudioBecomingNoisy(true)
.build() .build()
// The processor finds a track's neighbours in play order, which
// depends on shuffle mode.
gainProcessor.shuffleEnabled = exo.shuffleModeEnabled
exo.addListener(
object : Player.Listener {
override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) {
gainProcessor.shuffleEnabled = shuffleModeEnabled
}
},
)
return exo
} }
/** /**
@@ -0,0 +1,139 @@
package com.fabledsword.minstrel.player.gain
import android.os.Bundle
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.common.Timeline
import androidx.media3.common.audio.AudioProcessor
import androidx.media3.common.audio.BaseAudioProcessor
import com.fabledsword.minstrel.settings.data.NormalizationBoost
import com.fabledsword.minstrel.settings.data.NormalizationMode
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
import java.nio.ByteBuffer
/**
* Applies each track's loudness gain inside ExoPlayer's audio sink (M464
* #5000), so the level changes on the exact sample a track starts, gapless
* transitions included. `Player.setVolume` could do neither: it stops at 1,
* and set from a transition callback it lands about two seconds late
* because the next track is already buffered (androidx/media#418).
*
* Media3 1.11 flushes the sink's processors at every item boundary with the
* playlist [Timeline] and the new item's period, which is how this knows
* which track it is processing and who its neighbours are.
*
* Boosts above unity are held under -1 dBFS by a peak limiter when the user
* chose one. In headroom mode the gain already stops short of the track's
* true peak, and the final clamp only guards against a bad measurement.
*
* Runs on the playback thread. [prefs] and [store] are read per buffer, so a
* changed preference or a gain that arrives mid-track applies at once,
* through a short ramp rather than a step.
*/
class GainAudioProcessor(
private val prefs: () -> NormalizationPrefs,
private val store: ReplayGainStore,
) : BaseAudioProcessor() {
/** Mirrors the player's shuffle mode, so neighbours are found in play order. */
@Volatile var shuffleEnabled: Boolean = false
private var currentId: String? = null
private var asAlbum = false
private val stage = GainStage()
override fun onConfigure(inputAudioFormat: AudioProcessor.AudioFormat): AudioProcessor.AudioFormat =
when (inputAudioFormat.encoding) {
C.ENCODING_PCM_16BIT, C.ENCODING_PCM_FLOAT -> inputAudioFormat
else -> AudioProcessor.AudioFormat.NOT_SET
}
override fun onFlush(streamMetadata: AudioProcessor.StreamMetadata) {
identify(streamMetadata)
// A new stream starts at its own level: ramping in from the previous
// track's gain would swell or dip its first moments.
stage.reset(inputAudioFormat.sampleRate, inputAudioFormat.channelCount, targetGain())
}
override fun onReset() {
currentId = null
asAlbum = false
}
override fun queueInput(inputBuffer: ByteBuffer) {
val size = inputBuffer.remaining()
if (size == 0) return
val out = replaceOutputBuffer(size)
val p = prefs()
val target = targetGain(p)
val limiting = p.mode != NormalizationMode.OFF && p.boost == NormalizationBoost.LIMITER
if (stage.isUnity(target, limiting)) {
out.put(inputBuffer)
} else {
stage.process(
input = inputBuffer,
out = out,
channels = inputAudioFormat.channelCount,
isFloat = inputAudioFormat.encoding == C.ENCODING_PCM_FLOAT,
target = target,
limiting = limiting,
)
}
out.flip()
}
private fun targetGain(p: NormalizationPrefs = prefs()): Float {
val id = currentId ?: return 1f
return GainMath.dbToLinear(GainMath.gainDb(p, store.get(id), asAlbum))
}
// Which track this stream is, and whether it is playing as part of its
// album. Also asks the store for the gains of the tracks coming up, so
// each is known before it starts.
private fun identify(meta: AudioProcessor.StreamMetadata) {
currentId = null
asAlbum = false
val uid = meta.periodUid
if (uid == null || meta.timeline.isEmpty) return
val timeline = meta.timeline
val index = timeline.getPeriodByUid(uid, Timeline.Period()).windowIndex
val window = Timeline.Window()
fun itemAt(i: Int): MediaItem? =
if (i == C.INDEX_UNSET) null else timeline.getWindow(i, window).mediaItem
fun nextOf(i: Int) = timeline.getNextWindowIndex(i, Player.REPEAT_MODE_OFF, shuffleEnabled)
val cur = itemAt(index)
val prev = itemAt(timeline.getPreviousWindowIndex(index, Player.REPEAT_MODE_OFF, shuffleEnabled))
val next = itemAt(nextOf(index))
if (cur != null) {
currentId = cur.mediaId
asAlbum = GainMath.playingAsAlbum(prev?.albumPosition(), cur.albumPosition(), next?.albumPosition())
}
val upcoming = mutableListOf<String>()
var i = index
while (i != C.INDEX_UNSET && upcoming.size < LOOKAHEAD) {
itemAt(i)?.let { upcoming += it.mediaId }
i = nextOf(i)
}
store.request(upcoming)
}
companion object {
/** MediaMetadata extras key holding the track's album id. */
const val EXTRA_ALBUM_ID = "minstrel.album_id"
private const val LOOKAHEAD = 20
/** Puts what [albumPosition] reads into a MediaItem's metadata extras. */
fun albumExtras(albumId: String, into: Bundle = Bundle()): Bundle =
into.apply { putString(EXTRA_ALBUM_ID, albumId) }
}
}
private fun MediaItem.albumPosition(): AlbumPosition = AlbumPosition(
albumId = mediaMetadata.extras?.getString(GainAudioProcessor.EXTRA_ALBUM_ID),
discNumber = mediaMetadata.discNumber,
trackNumber = mediaMetadata.trackNumber,
)
@@ -0,0 +1,96 @@
package com.fabledsword.minstrel.player.gain
import com.fabledsword.minstrel.settings.data.NormalizationBoost
import com.fabledsword.minstrel.settings.data.NormalizationMode
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
import kotlin.math.log10
import kotlin.math.min
import kotlin.math.pow
/**
* One track's ReplayGain 2.0 values from the server (#4997): dB to the
* -18 LUFS reference, and linear true peaks. A null field has not been
* measured yet.
*/
data class ReplayGain(
val trackGain: Float?,
val trackPeak: Float?,
val albumGain: Float?,
val albumPeak: Float?,
) {
companion object {
val NONE = ReplayGain(null, null, null, null)
}
}
/** Where a queue item sits in its album, for the auto-mode album rule. */
data class AlbumPosition(val albumId: String?, val discNumber: Int?, val trackNumber: Int?)
/**
* Loudness-normalization math (M464 #5000). The same rules as the web
* player's `web/src/lib/player/gain.ts`, so a track levels the same on
* every device.
*/
object GainMath {
private const val REFERENCE_LUFS = -18
/** Headroom mode raises a quiet track only until its true peak reaches this. */
const val PEAK_CEILING_DBTP = -1f
/**
* No track is raised more than this, whatever its measurement says: a
* near-silent track would otherwise come out as amplified noise.
*/
const val MAX_BOOST_DB = 12f
private const val DISC_STRIDE = 1000
// Amplitude decibels: 20 dB per factor of ten.
private const val DB_PER_DECADE = 20f
/**
* The gain to apply, in dB. 0 when leveling is off or the track has not
* been measured: an unmeasured track plays as mastered.
*/
fun gainDb(prefs: NormalizationPrefs, g: ReplayGain?, asAlbum: Boolean): Float {
if (prefs.mode == NormalizationMode.OFF || g == null) return 0f
val wantAlbum = prefs.mode == NormalizationMode.ALBUM ||
(prefs.mode == NormalizationMode.AUTO && asAlbum)
// Album gain falls back to track gain while the album is still being
// measured; track gain never falls back to album gain.
val useAlbum = wantAlbum && g.albumGain != null
val gain = if (useAlbum) g.albumGain else g.trackGain
val peak = if (useAlbum) g.albumPeak else g.trackPeak
return gain?.let { leveled(prefs, it, peak) } ?: 0f
}
private fun leveled(prefs: NormalizationPrefs, gain: Float, peak: Float?): Float {
var db = gain + (prefs.targetLufs - REFERENCE_LUFS)
if (prefs.boost == NormalizationBoost.HEADROOM && peak != null && peak > 0f) {
db = min(db, PEAK_CEILING_DBTP - DB_PER_DECADE * log10(peak))
}
return min(db, MAX_BOOST_DB)
}
/**
* Whether the current item is being played as part of its album, in
* order: a neighbour in play order is from the same album and sits on
* the right side of it. That is when album gain keeps the album's own
* dynamics (a quiet intro stays quiet); anywhere else track gain levels
* the mix.
*/
fun playingAsAlbum(prev: AlbumPosition?, cur: AlbumPosition, next: AlbumPosition?): Boolean {
val curOrder = order(cur)
if (cur.albumId == null || curOrder == null) return false
val prevOrder = prev?.takeIf { it.albumId == cur.albumId }?.let { order(it) }
val nextOrder = next?.takeIf { it.albumId == cur.albumId }?.let { order(it) }
return (prevOrder != null && prevOrder < curOrder) || (nextOrder != null && nextOrder > curOrder)
}
// Disc-major track order. A track with no number has no place in the
// order and is never evidence of album play.
private fun order(p: AlbumPosition): Int? =
p.trackNumber?.let { (p.discNumber ?: 1) * DISC_STRIDE + it }
fun dbToLinear(db: Float): Float = 10f.pow(db / DB_PER_DECADE)
}
@@ -0,0 +1,82 @@
package com.fabledsword.minstrel.player.gain
import java.nio.ByteBuffer
import kotlin.math.abs
import kotlin.math.exp
import kotlin.math.max
import kotlin.math.roundToInt
/**
* The sample arithmetic of [GainAudioProcessor], apart from Media3 so it can
* be tested on the JVM: a gain that ramps toward its target, then an
* optional peak limiter, then a clamp to full scale. Interleaved 16-bit or
* float PCM in, the same format out.
*/
internal class GainStage {
private var gain = 1f
private var envelope = 0f
private var rampCoeff = 1f
private var releaseCoeff = 0f
private var frame = FloatArray(2)
/** Sets the sample rate and channel count, and starts the gain at [startGain]. */
fun reset(sampleRate: Int, channels: Int, startGain: Float) {
val rate = sampleRate.coerceAtLeast(1).toFloat()
rampCoeff = 1f - exp(-1f / (RAMP_SECONDS * rate))
releaseCoeff = exp(-1f / (RELEASE_SECONDS * rate))
if (frame.size < channels) frame = FloatArray(channels)
gain = startGain
envelope = 0f
}
/** True when processing would copy the input unchanged. */
fun isUnity(target: Float, limiting: Boolean): Boolean = gain == 1f && target == 1f && !limiting
/**
* Processes every whole frame of [input] into [out]. A trailing partial
* frame is dropped, as Media3's own processors do.
*/
@Suppress("LongParameterList") // the PCM layout is four facts; a holder type would only rename them
fun process(
input: ByteBuffer,
out: ByteBuffer,
channels: Int,
isFloat: Boolean,
target: Float,
limiting: Boolean,
) {
val bytesPerFrame = channels * if (isFloat) FLOAT_BYTES else PCM16_BYTES
val frames = input.remaining() / bytesPerFrame
repeat(frames) {
gain += (target - gain) * rampCoeff
var peak = 0f
for (c in 0 until channels) {
val s = (if (isFloat) input.float else input.short / PCM16_SCALE) * gain
frame[c] = s
peak = max(peak, abs(s))
}
var reduce = 1f
if (limiting) {
envelope = max(peak, envelope * releaseCoeff)
if (envelope > LIMIT_CEILING) reduce = LIMIT_CEILING / envelope
}
for (c in 0 until channels) {
val v = (frame[c] * reduce).coerceIn(-1f, 1f)
if (isFloat) out.putFloat(v) else out.putShort((v * PCM16_MAX).roundToInt().toShort())
}
}
input.position(input.limit())
}
companion object {
/** -1 dBFS, the ceiling the web player's limiter holds too. */
const val LIMIT_CEILING = 0.8913f
private const val RAMP_SECONDS = 0.05f
private const val RELEASE_SECONDS = 0.25f
private const val PCM16_SCALE = 32768f
private const val PCM16_MAX = 32767f
private const val PCM16_BYTES = 2
private const val FLOAT_BYTES = 4
}
}
@@ -0,0 +1,140 @@
package com.fabledsword.minstrel.player.gain
import com.fabledsword.minstrel.api.endpoints.ReplayGainApi
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.ServerHealth
import com.fabledsword.minstrel.di.ApplicationScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import retrofit2.Retrofit
import retrofit2.create
import timber.log.Timber
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton
/**
* The gains the player levels by (M464 #5000), looked up per track and
* held for the life of the process. The audio thread reads them with [get];
* [request] fills them in the background.
*
* Sources, most to least preferred:
* 1. the library cache, synced with the gains, so cached audio levels
* offline;
* 2. the server's replay-gain lookup, for a track not cached yet or
* measured since the last sync;
* 3. none: the track plays as mastered, and the player picks the gain up
* the moment it lands.
*/
@Singleton
class ReplayGainStore internal constructor(
private val scope: CoroutineScope,
private val trackDao: CachedTrackDao,
private val api: ReplayGainApi,
private val serverHealth: () -> ServerHealth,
private val clock: () -> Long,
) {
@Inject constructor(
@ApplicationScope scope: CoroutineScope,
trackDao: CachedTrackDao,
retrofit: Retrofit,
network: NetworkStatusController,
) : this(
scope = scope,
trackDao = trackDao,
api = retrofit.create(),
serverHealth = { network.state.value },
clock = System::currentTimeMillis,
)
private val gains = ConcurrentHashMap<String, ReplayGain>()
private val inflight: MutableSet<String> = ConcurrentHashMap.newKeySet()
// When the server last said it had nothing for a track. Not cached for
// good: the backfill may measure it minutes later.
private val missedAt = ConcurrentHashMap<String, Long>()
/** The track's gains, or null while they are unknown. Safe from any thread. */
fun get(trackId: String): ReplayGain? = gains[trackId]
/** Starts loading gains for any of [trackIds] not already known or loading. */
fun request(trackIds: Collection<String>) {
val now = clock()
val wanted = trackIds.filter { id ->
id !in gains &&
(missedAt[id]?.let { now - it > MISS_RETRY_MS } ?: true) &&
inflight.add(id)
}
if (wanted.isEmpty()) return
scope.launch {
try {
load(wanted)
} finally {
inflight.removeAll(wanted.toSet())
}
}
}
internal suspend fun load(ids: List<String>) {
fromCache(ids)
val rest = ids.filter { it !in gains }
val health = serverHealth()
if (rest.isEmpty() || health == ServerHealth.Offline || health == ServerHealth.ServerDown) return
for (batch in rest.chunked(MAX_IDS_PER_REQUEST)) {
// A failed lookup leaves the batch unknown; the next request asks again.
if (!fromServer(batch)) return
}
}
private suspend fun fromCache(ids: List<String>) {
val rows = try {
trackDao.replayGains(ids)
} catch (e: CancellationException) {
throw e
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
Timber.w(e, "replay gain: cache read failed")
emptyList()
}
for (row in rows) {
// A cached row without a track gain is not an answer: the server
// may have measured the track since this device last synced.
if (row.trackGain == null) continue
gains[row.id] = ReplayGain(row.trackGain, row.trackPeak, row.albumGain, row.albumPeak)
}
}
/** Asks the server about [batch]; false when it could not be asked. */
private suspend fun fromServer(batch: List<String>): Boolean {
val res = try {
withTimeoutOrNull(REQUEST_TIMEOUT_MS) { api.getReplayGain(batch.joinToString(",")) }
} catch (e: CancellationException) {
throw e
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
Timber.w(e, "replay gain: lookup failed")
null
} ?: return false
val now = clock()
for (id in batch) {
val w = res.items[id]
if (w == null) {
missedAt[id] = now
} else {
gains[id] = ReplayGain(w.trackGain, w.trackPeak, w.albumGain, w.albumPeak)
}
}
return true
}
private companion object {
const val MAX_IDS_PER_REQUEST = 200 // the endpoint's limit
const val REQUEST_TIMEOUT_MS = 10_000L
const val MISS_RETRY_MS = 5 * 60_000L
}
}
@@ -0,0 +1,111 @@
package com.fabledsword.minstrel.player.gain
import com.fabledsword.minstrel.settings.data.NormalizationBoost
import com.fabledsword.minstrel.settings.data.NormalizationMode
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
import org.junit.jupiter.api.Test
import kotlin.math.log10
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/** Mirrors web/src/lib/player/gain.test.ts: both players must level alike. */
class GainMathTest {
private fun prefs(
mode: NormalizationMode = NormalizationMode.AUTO,
target: Int = -18,
boost: NormalizationBoost = NormalizationBoost.HEADROOM,
) = NormalizationPrefs(mode = mode, targetLufs = target, boost = boost)
private val g = ReplayGain(trackGain = -6f, trackPeak = 1f, albumGain = -4f, albumPeak = 1f)
@Test
fun `off and unmeasured tracks play as mastered`() {
assertEquals(0f, GainMath.gainDb(prefs(mode = NormalizationMode.OFF), g, false))
assertEquals(0f, GainMath.gainDb(prefs(), null, false))
assertEquals(0f, GainMath.gainDb(prefs(), ReplayGain.NONE, false))
}
@Test
fun `mode picks the gain and auto follows album play`() {
assertEquals(-6f, GainMath.gainDb(prefs(mode = NormalizationMode.TRACK), g, true))
assertEquals(-4f, GainMath.gainDb(prefs(mode = NormalizationMode.ALBUM), g, false))
assertEquals(-4f, GainMath.gainDb(prefs(), g, true))
assertEquals(-6f, GainMath.gainDb(prefs(), g, false))
}
@Test
fun `album gain falls back to track gain until the album is measured`() {
val partial = ReplayGain(trackGain = -6f, trackPeak = 1f, albumGain = null, albumPeak = null)
assertEquals(-6f, GainMath.gainDb(prefs(mode = NormalizationMode.ALBUM), partial, true))
}
@Test
fun `a louder target raises every gain by the difference`() {
assertEquals(-2f, GainMath.gainDb(prefs(mode = NormalizationMode.TRACK, target = -14), g, false))
}
@Test
fun `headroom stops a boost 1 dB under the true peak and the limiter lets it through`() {
val quiet = ReplayGain(trackGain = 8f, trackPeak = 0.5f, albumGain = null, albumPeak = null)
val headroom = GainMath.gainDb(prefs(mode = NormalizationMode.TRACK), quiet, false)
assertEquals(-1f - 20f * log10(0.5f), headroom, 1e-4f)
val limited = GainMath.gainDb(
prefs(mode = NormalizationMode.TRACK, boost = NormalizationBoost.LIMITER),
quiet,
false,
)
assertEquals(8f, limited)
}
@Test
fun `no boost passes the cap`() {
val silent = ReplayGain(trackGain = 30f, trackPeak = 0.001f, albumGain = null, albumPeak = null)
val db = GainMath.gainDb(
prefs(mode = NormalizationMode.TRACK, boost = NormalizationBoost.LIMITER),
silent,
false,
)
assertEquals(GainMath.MAX_BOOST_DB, db)
}
@Test
fun `cuts are never limited by the peak`() {
val hot = ReplayGain(trackGain = -9f, trackPeak = 1.4f, albumGain = null, albumPeak = null)
assertEquals(-9f, GainMath.gainDb(prefs(mode = NormalizationMode.TRACK), hot, false))
}
private fun pos(album: String, track: Int?, disc: Int? = 1) = AlbumPosition(album, disc, track)
@Test
fun `in-order neighbours from the same album are album play`() {
assertTrue(GainMath.playingAsAlbum(null, pos("x", 1), pos("x", 2)))
assertTrue(GainMath.playingAsAlbum(pos("x", 1), pos("x", 2), pos("x", 3)))
assertTrue(GainMath.playingAsAlbum(pos("x", 2), pos("x", 3), null))
}
@Test
fun `a shuffled album, a mix and a lone track are not album play`() {
assertFalse(GainMath.playingAsAlbum(null, pos("x", 3), pos("x", 1)))
assertFalse(GainMath.playingAsAlbum(null, pos("x", 1), pos("y", 2)))
assertFalse(GainMath.playingAsAlbum(null, pos("x", 1), null))
}
@Test
fun `disc order counts`() {
assertTrue(GainMath.playingAsAlbum(pos("x", 12, disc = 1), pos("x", 1, disc = 2), null))
}
@Test
fun `a neighbour without a track number is not evidence of order`() {
assertFalse(GainMath.playingAsAlbum(pos("x", null), pos("x", 3), null))
assertFalse(GainMath.playingAsAlbum(null, pos("x", null), pos("x", null)))
}
@Test
fun `dbToLinear`() {
assertEquals(1f, GainMath.dbToLinear(0f))
assertEquals(0.1f, GainMath.dbToLinear(-20f), 1e-6f)
}
}
@@ -0,0 +1,73 @@
package com.fabledsword.minstrel.player.gain
import org.junit.jupiter.api.Test
import java.nio.ByteBuffer
import java.nio.ByteOrder
import kotlin.math.abs
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class GainStageTest {
private fun floats(vararg v: Float): ByteBuffer =
ByteBuffer.allocateDirect(v.size * 4).order(ByteOrder.nativeOrder()).apply {
v.forEach { putFloat(it) }
flip()
}
private fun shorts(vararg v: Short): ByteBuffer =
ByteBuffer.allocateDirect(v.size * 2).order(ByteOrder.nativeOrder()).apply {
v.forEach { putShort(it) }
flip()
}
private fun runFloat(stage: GainStage, input: ByteBuffer, target: Float, limiting: Boolean): FloatArray {
val out = ByteBuffer.allocateDirect(input.remaining()).order(ByteOrder.nativeOrder())
stage.process(input, out, channels = 2, isFloat = true, target = target, limiting = limiting)
out.flip()
return FloatArray(out.remaining() / 4) { out.float }
}
@Test
fun `a stream that starts at its gain is scaled from the first sample`() {
val stage = GainStage().apply { reset(sampleRate = 44_100, channels = 2, startGain = 0.5f) }
val out = runFloat(stage, floats(0.8f, -0.4f), target = 0.5f, limiting = false)
assertEquals(0.4f, out[0], 1e-6f)
assertEquals(-0.2f, out[1], 1e-6f)
}
@Test
fun `a changed target ramps rather than steps`() {
val stage = GainStage().apply { reset(sampleRate = 44_100, channels = 2, startGain = 1f) }
val n = 44_100 // one second of stereo frames
val input = floats(*FloatArray(n * 2) { 0.5f })
val out = runFloat(stage, input, target = 0.5f, limiting = false)
assertTrue(out[0] > 0.49f, "first frame barely moved: ${out[0]}")
assertEquals(0.25f, out.last(), 1e-3f)
}
@Test
fun `the limiter holds a boosted peak at -1 dBFS`() {
val stage = GainStage().apply { reset(sampleRate = 44_100, channels = 2, startGain = 4f) }
val out = runFloat(stage, floats(0.9f, -0.9f, 0.1f, 0.1f), target = 4f, limiting = true)
out.forEach { assertTrue(abs(it) <= GainStage.LIMIT_CEILING + 1e-6f, "sample $it over the ceiling") }
}
@Test
fun `without the limiter a boost is clamped to full scale, 16-bit included`() {
val stage = GainStage().apply { reset(sampleRate = 44_100, channels = 2, startGain = 4f) }
val input = shorts(20_000, -20_000)
val out = ByteBuffer.allocateDirect(4).order(ByteOrder.nativeOrder())
stage.process(input, out, channels = 2, isFloat = false, target = 4f, limiting = false)
out.flip()
assertEquals(Short.MAX_VALUE, out.short)
assertEquals((-Short.MAX_VALUE).toShort(), out.short)
}
@Test
fun `unity is a pass-through`() {
val stage = GainStage().apply { reset(sampleRate = 44_100, channels = 2, startGain = 1f) }
assertTrue(stage.isUnity(target = 1f, limiting = false))
assertTrue(!stage.isUnity(target = 1f, limiting = true))
}
}
@@ -0,0 +1,74 @@
package com.fabledsword.minstrel.player.gain
import com.fabledsword.minstrel.api.endpoints.ReplayGainApi
import com.fabledsword.minstrel.cache.db.dao.CachedReplayGain
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
import com.fabledsword.minstrel.connectivity.ServerHealth
import com.fabledsword.minstrel.models.wire.ReplayGainResponseWire
import com.fabledsword.minstrel.models.wire.ReplayGainWire
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import java.io.IOException
import kotlin.test.assertEquals
import kotlin.test.assertNull
/** The fallback ladder: the library cache, then the server, then nothing. */
class ReplayGainStoreTest {
private val dao: CachedTrackDao = mockk()
private val api: ReplayGainApi = mockk()
private var health = ServerHealth.Healthy
private fun store() = ReplayGainStore(
scope = TestScope(),
trackDao = dao,
api = api,
serverHealth = { health },
clock = { 0L },
)
@Test
fun `cached gains are used and only the rest are asked of the server`() = runTest {
coEvery { dao.replayGains(listOf("a", "b", "c")) } returns listOf(
CachedReplayGain("a", trackGain = -6f, trackPeak = 1f, albumGain = -4f, albumPeak = 1f),
// Cached but unmeasured at the last sync: the server may know more.
CachedReplayGain("b", trackGain = null, trackPeak = null, albumGain = null, albumPeak = null),
)
coEvery { api.getReplayGain("b,c") } returns ReplayGainResponseWire(
items = mapOf("b" to ReplayGainWire(trackGain = -3f, trackPeak = 0.9f)),
)
val s = store()
s.load(listOf("a", "b", "c"))
assertEquals(-4f, s.get("a")?.albumGain)
assertEquals(-3f, s.get("b")?.trackGain)
assertNull(s.get("c"), "a track the server has not measured stays unknown")
}
@Test
fun `offline, the cache is all there is`() = runTest {
health = ServerHealth.Offline
coEvery { dao.replayGains(any()) } returns emptyList()
val s = store()
s.load(listOf("a"))
assertNull(s.get("a"))
coVerify(exactly = 0) { api.getReplayGain(any()) }
}
@Test
fun `a failed lookup leaves the track unknown, to be asked again`() = runTest {
coEvery { dao.replayGains(any()) } returns emptyList()
coEvery { api.getReplayGain(any()) } throws IOException("reset")
val s = store()
s.load(listOf("a"))
assertNull(s.get("a"))
}
}
+1 -1
View File
@@ -21,7 +21,7 @@ kotlinx-serialization = "1.7.3"
kotlinx-coroutines = "1.9.0" kotlinx-coroutines = "1.9.0"
kotlinx-datetime = "0.6.1" kotlinx-datetime = "0.6.1"
kotlinx-serialization-converter = "1.0.0" kotlinx-serialization-converter = "1.0.0"
media3 = "1.10.1" media3 = "1.11.0"
mediarouter = "1.7.0" mediarouter = "1.7.0"
coil = "3.0.0-rc02" coil = "3.0.0-rc02"
palette = "1.0.0" palette = "1.0.0"
+1
View File
@@ -76,6 +76,7 @@ describe('playingAsAlbum', () => {
test('neighbours without track numbers are not evidence of order', () => { test('neighbours without track numbers are not evidence of order', () => {
expect(playingAsAlbum([track('1', 'x'), track('2', 'x')], 0)).toBe(false); expect(playingAsAlbum([track('1', 'x'), track('2', 'x')], 0)).toBe(false);
expect(playingAsAlbum([track('1', 'x'), track('2', 'x', 3)], 1)).toBe(false);
}); });
}); });
+14 -12
View File
@@ -20,20 +20,22 @@ export const MAX_BOOST_DB = 12;
*/ */
export function playingAsAlbum(queue: TrackRef[], index: number): boolean { export function playingAsAlbum(queue: TrackRef[], index: number): boolean {
const cur = queue[index]; const cur = queue[index];
if (!cur) return false; const curOrder = cur ? order(cur) : null;
const prev = queue[index - 1]; if (!cur || curOrder === null) return false;
const next = queue[index + 1]; const inOrder = (n: TrackRef | undefined, before: boolean): boolean => {
return ( if (!n || n.album_id !== cur.album_id) return false;
(prev !== undefined && prev.album_id === cur.album_id && order(prev) < order(cur)) || const o = order(n);
(next !== undefined && next.album_id === cur.album_id && order(next) > order(cur)) if (o === null) return false;
); return before ? o < curOrder : o > curOrder;
};
return inOrder(queue[index - 1], true) || inOrder(queue[index + 1], false);
} }
// Disc-major track order. Tracks without numbers compare as equal and so // Disc-major track order. A track with no number has no place in the order
// never count as in order; a same-album neighbour with no numbers is not // and is never evidence of album play.
// evidence of album play. function order(t: TrackRef): number | null {
function order(t: TrackRef): number { if (t.track_number == null) return null;
return (t.disc_number ?? 1) * 1000 + (t.track_number ?? 0); return (t.disc_number ?? 1) * 1000 + t.track_number;
} }
/** /**