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
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:
@@ -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
|
||||
}
|
||||
+20
-1
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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()
|
||||
|
||||
|
||||
+21
@@ -38,6 +38,27 @@ interface CachedTrackDao {
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
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)")
|
||||
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?,
|
||||
)
|
||||
|
||||
+3
@@ -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(),
|
||||
)
|
||||
|
||||
+4
@@ -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(),
|
||||
)
|
||||
|
||||
+4
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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("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,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<AudioProcessor>(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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user