From 1013c283daf285219f6ad6ba43b13c918159b7c6 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 6 Oct 2026 19:10:35 -0400 Subject: [PATCH] feat(android): level playback with a gain processor in the audio sink (M464 #5000) 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 --- .../minstrel/api/endpoints/ReplayGainApi.kt | 15 ++ .../minstrel/cache/db/AppDatabase.kt | 21 ++- .../minstrel/cache/db/DatabaseModule.kt | 2 +- .../minstrel/cache/db/dao/CachedTrackDao.kt | 21 +++ .../cache/db/entities/CachedAlbumEntity.kt | 3 + .../cache/db/entities/CachedTrackEntity.kt | 4 + .../minstrel/cache/sync/SyncController.kt | 4 + .../minstrel/models/wire/ReplayGainWire.kt | 18 +++ .../minstrel/models/wire/SyncResponseWire.kt | 7 + .../minstrel/player/MinstrelPlayerService.kt | 20 +++ .../minstrel/player/PlayerController.kt | 17 ++- .../minstrel/player/PlayerFactory.kt | 43 +++++- .../player/gain/GainAudioProcessor.kt | 139 +++++++++++++++++ .../minstrel/player/gain/GainMath.kt | 96 ++++++++++++ .../minstrel/player/gain/GainStage.kt | 82 ++++++++++ .../minstrel/player/gain/ReplayGainStore.kt | 140 ++++++++++++++++++ .../minstrel/player/gain/GainMathTest.kt | 111 ++++++++++++++ .../minstrel/player/gain/GainStageTest.kt | 73 +++++++++ .../player/gain/ReplayGainStoreTest.kt | 74 +++++++++ android/gradle/libs.versions.toml | 2 +- web/src/lib/player/gain.test.ts | 1 + web/src/lib/player/gain.ts | 26 ++-- 22 files changed, 902 insertions(+), 17 deletions(-) create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/ReplayGainApi.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/models/wire/ReplayGainWire.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/player/gain/GainAudioProcessor.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/player/gain/GainMath.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/player/gain/GainStage.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/player/gain/ReplayGainStore.kt create mode 100644 android/app/src/test/java/com/fabledsword/minstrel/player/gain/GainMathTest.kt create mode 100644 android/app/src/test/java/com/fabledsword/minstrel/player/gain/GainStageTest.kt create mode 100644 android/app/src/test/java/com/fabledsword/minstrel/player/gain/ReplayGainStoreTest.kt diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/ReplayGainApi.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/ReplayGainApi.kt new file mode 100644 index 00000000..d6356c87 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/ReplayGainApi.kt @@ -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 +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/AppDatabase.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/AppDatabase.kt index 5573f6bb..49dc21db 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/AppDatabase.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/AppDatabase.kt @@ -67,6 +67,10 @@ import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity AuthSessionEntity::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 // preference (M464 #4998). The first schema step with an explicit // 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 — // which is exactly right here: the next sync refills every row with the // new column populated, so there is nothing to migrate by hand. - version = 9, + version = 10, exportSchema = true, ) @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") } } + +/** + * 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") + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/DatabaseModule.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/DatabaseModule.kt index 883bf004..db7e4f06 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/DatabaseModule.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/DatabaseModule.kt @@ -37,7 +37,7 @@ object DatabaseModule { // launch, so users lose only the unsynced mutation queue // (acceptable while we're iterating). Replace with explicit // Migration entries before the first tagged release. - .addMigrations(MIGRATION_8_9) + .addMigrations(MIGRATION_8_9, MIGRATION_9_10) .fallbackToDestructiveMigration(dropAllTables = true) .build() diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedTrackDao.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedTrackDao.kt index a65fa09e..da1894dc 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedTrackDao.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedTrackDao.kt @@ -38,6 +38,27 @@ interface CachedTrackDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertAll(rows: List) + /** + * 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): List + @Query("DELETE FROM cached_tracks WHERE id IN (:ids)") suspend fun deleteByIds(ids: List) } + +/** One row of [CachedTrackDao.replayGains]. */ +data class CachedReplayGain( + val id: String, + val trackGain: Float?, + val trackPeak: Float?, + val albumGain: Float?, + val albumPeak: Float?, +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedAlbumEntity.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedAlbumEntity.kt index 05274bda..b2a22dfa 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedAlbumEntity.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedAlbumEntity.kt @@ -18,5 +18,8 @@ data class CachedAlbumEntity( val releaseDate: String? = null, val coverPath: 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(), ) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedTrackEntity.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedTrackEntity.kt index d8fe1624..f3cff52d 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedTrackEntity.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedTrackEntity.kt @@ -26,5 +26,9 @@ data class CachedTrackEntity( val fileFormat: String? = null, val genre: String? = null, 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(), ) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/sync/SyncController.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/sync/SyncController.kt index a7c8620b..ba290b9d 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/sync/SyncController.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/sync/SyncController.kt @@ -206,6 +206,8 @@ private fun SyncAlbumWire.toEntity(): CachedAlbumEntity = CachedAlbumEntity( releaseDate = releaseDate, coverPath = coverArtPath, mbid = mbid, + albumGain = albumGain, + albumPeak = albumPeak, ) private fun SyncTrackWire.toEntity(): CachedTrackEntity = CachedTrackEntity( @@ -220,4 +222,6 @@ private fun SyncTrackWire.toEntity(): CachedTrackEntity = CachedTrackEntity( fileFormat = fileFormat, genre = genre, missing = missing, + trackGain = trackGain, + trackPeak = trackPeak, ) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/ReplayGainWire.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/ReplayGainWire.kt new file mode 100644 index 00000000..b58a9254 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/ReplayGainWire.kt @@ -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 = emptyMap(), +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/SyncResponseWire.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/SyncResponseWire.kt index eb107be8..67e75e61 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/SyncResponseWire.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/SyncResponseWire.kt @@ -28,6 +28,10 @@ data class SyncAlbumWire( @SerialName("release_date") val releaseDate: String? = null, @SerialName("cover_art_path") val coverArtPath: 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 @@ -51,6 +55,9 @@ data class SyncTrackWire( // its tracks stay playable, which is the correct reading of "this server // has nothing to say about missing files". 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, ) /** diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelPlayerService.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelPlayerService.kt index 59741113..f0d21efa 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelPlayerService.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelPlayerService.kt @@ -12,8 +12,10 @@ import androidx.media3.session.SessionCommand import com.fabledsword.minstrel.MainActivity import com.fabledsword.minstrel.likes.data.LikesRepository import com.fabledsword.minstrel.likes.data.LikesRepository.Companion.ENTITY_TRACK +import com.fabledsword.minstrel.settings.data.NormalizationRepository import com.google.common.collect.ImmutableList import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -25,6 +27,7 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.launch +import timber.log.Timber import javax.inject.Inject /** @@ -66,6 +69,8 @@ class MinstrelPlayerService : MediaSessionService() { @Inject lateinit var likesRepository: LikesRepository + @Inject lateinit var normalizationRepository: NormalizationRepository + private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) private var mediaSession: MediaSession? = null @@ -82,6 +87,21 @@ class MinstrelPlayerService : MediaSessionService() { .build() mediaSession = session 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") + } } /** diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt index 44c1b01c..87f09513 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt @@ -14,6 +14,8 @@ import androidx.media3.session.MediaController import androidx.media3.session.SessionToken import com.fabledsword.minstrel.di.ApplicationScope 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.toPlayableTrackRefs import com.fabledsword.minstrel.shared.resolveServerUrl @@ -69,6 +71,7 @@ class PlayerController @Inject constructor( private val playerFactory: PlayerFactory, private val activeUpnpHolder: com.fabledsword.minstrel.player.output.ActiveUpnpHolder, private val remoteState: RemotePlayerState, + private val replayGains: ReplayGainStore, ) { /** @@ -277,6 +280,11 @@ class PlayerController @Inject constructor( queueRefs = playable.tracks val items = playable.tracks.map { it.toMediaItem(source) } 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 // context after awaitReady() unblocks (ResumeController launches // 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 // paused under UPnP (it never probes a duration in that state). 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 // cover the in-app surfaces use (TrackRef.coverUrl -> // /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" private const val MS_PER_SECOND = 1_000L private const val MAX_INTERPOLATION_DRIFT_MS = 5_000L + private const val GAIN_PREFETCH = 20 } } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerFactory.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerFactory.kt index 741c024c..0b655d86 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerFactory.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerFactory.kt @@ -4,6 +4,7 @@ import android.content.Context import androidx.media3.common.AudioAttributes import androidx.media3.common.C import androidx.media3.common.Player +import androidx.media3.common.audio.AudioProcessor import androidx.media3.common.util.BitmapLoader import androidx.media3.database.StandaloneDatabaseProvider 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.SimpleCache import androidx.media3.datasource.okhttp.OkHttpDataSource +import androidx.media3.exoplayer.DefaultRenderersFactory 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.session.CacheBitmapLoader +import com.fabledsword.minstrel.auth.AuthStore 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 dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.channels.BufferOverflow @@ -56,6 +63,8 @@ class PlayerFactory @Inject constructor( private val activeUpnpHolder: ActiveUpnpHolder, private val remoteState: RemotePlayerState, 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() } @@ -142,7 +151,28 @@ class PlayerFactory @Inject constructor( val mediaSourceFactory = DefaultMediaSourceFactory(context) .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(gainProcessor)) + .build() + } + + val exo = ExoPlayer.Builder(context, renderersFactory) .setMediaSourceFactory(mediaSourceFactory) .setAudioAttributes( AudioAttributes.Builder() @@ -153,6 +183,17 @@ class PlayerFactory @Inject constructor( ) .setHandleAudioBecomingNoisy(true) .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 } /** diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/gain/GainAudioProcessor.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/gain/GainAudioProcessor.kt new file mode 100644 index 00000000..8baa70d0 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/gain/GainAudioProcessor.kt @@ -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() + 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, +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/gain/GainMath.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/gain/GainMath.kt new file mode 100644 index 00000000..b72fee8e --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/gain/GainMath.kt @@ -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) +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/gain/GainStage.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/gain/GainStage.kt new file mode 100644 index 00000000..d8d40c12 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/gain/GainStage.kt @@ -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 + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/gain/ReplayGainStore.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/gain/ReplayGainStore.kt new file mode 100644 index 00000000..3c2042a4 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/gain/ReplayGainStore.kt @@ -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() + private val inflight: MutableSet = 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() + + /** 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) { + 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) { + 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) { + 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): 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 + } +} diff --git a/android/app/src/test/java/com/fabledsword/minstrel/player/gain/GainMathTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/player/gain/GainMathTest.kt new file mode 100644 index 00000000..2b6bc744 --- /dev/null +++ b/android/app/src/test/java/com/fabledsword/minstrel/player/gain/GainMathTest.kt @@ -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) + } +} diff --git a/android/app/src/test/java/com/fabledsword/minstrel/player/gain/GainStageTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/player/gain/GainStageTest.kt new file mode 100644 index 00000000..6995c2b1 --- /dev/null +++ b/android/app/src/test/java/com/fabledsword/minstrel/player/gain/GainStageTest.kt @@ -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)) + } +} diff --git a/android/app/src/test/java/com/fabledsword/minstrel/player/gain/ReplayGainStoreTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/player/gain/ReplayGainStoreTest.kt new file mode 100644 index 00000000..5fccb71c --- /dev/null +++ b/android/app/src/test/java/com/fabledsword/minstrel/player/gain/ReplayGainStoreTest.kt @@ -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")) + } +} diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 176c8c31..a551209c 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -21,7 +21,7 @@ kotlinx-serialization = "1.7.3" kotlinx-coroutines = "1.9.0" kotlinx-datetime = "0.6.1" kotlinx-serialization-converter = "1.0.0" -media3 = "1.10.1" +media3 = "1.11.0" mediarouter = "1.7.0" coil = "3.0.0-rc02" palette = "1.0.0" diff --git a/web/src/lib/player/gain.test.ts b/web/src/lib/player/gain.test.ts index 0281907a..2f6db039 100644 --- a/web/src/lib/player/gain.test.ts +++ b/web/src/lib/player/gain.test.ts @@ -76,6 +76,7 @@ describe('playingAsAlbum', () => { 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', 3)], 1)).toBe(false); }); }); diff --git a/web/src/lib/player/gain.ts b/web/src/lib/player/gain.ts index ecb0324f..0fd81e7d 100644 --- a/web/src/lib/player/gain.ts +++ b/web/src/lib/player/gain.ts @@ -20,20 +20,22 @@ export const MAX_BOOST_DB = 12; */ export function playingAsAlbum(queue: TrackRef[], index: number): boolean { const cur = queue[index]; - if (!cur) return false; - const prev = queue[index - 1]; - const next = queue[index + 1]; - return ( - (prev !== undefined && prev.album_id === cur.album_id && order(prev) < order(cur)) || - (next !== undefined && next.album_id === cur.album_id && order(next) > order(cur)) - ); + const curOrder = cur ? order(cur) : null; + if (!cur || curOrder === null) return false; + const inOrder = (n: TrackRef | undefined, before: boolean): boolean => { + if (!n || n.album_id !== cur.album_id) return false; + const o = order(n); + 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 -// never count as in order; a same-album neighbour with no numbers is not -// evidence of album play. -function order(t: TrackRef): number { - return (t.disc_number ?? 1) * 1000 + (t.track_number ?? 0); +// Disc-major track order. A track with no number has no place in the order +// and is never evidence of album play. +function order(t: TrackRef): number | null { + if (t.track_number == null) return null; + return (t.disc_number ?? 1) * 1000 + t.track_number; } /**