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