From af36b2f24a19e8c78afda04a85950408711964cd Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Tue, 6 Oct 2026 14:29:40 -0400
Subject: [PATCH 01/12] feat: per-user volume leveling preference, synced
across devices (M464 #4998)
Mode (off, auto, track, album), target (-18, -16, -14 LUFS) and boost
(within headroom, or fully with a limiter), stored per user on the server
so the web player, the Android app and casts apply the same one.
- Server: user_normalization_prefs (migration 0067), GET/PUT
/api/me/normalization; a whole-body PUT, validated, last write wins.
- Web: Settings > Playback > Volume leveling. Saves at once, restores the
old choice if the save fails, and caches the value for the player.
- Android: Settings card. The device keeps a copy for offline playback
(Room v9 with an explicit migration, so the upgrade wipes nothing).
Writes are offline-first: shown at once, PUT best effort, queued on
failure (NORMALIZATION_SET, collapsed to the newest). A refresh never
overwrites a change still queued.
Co-Authored-By: Claude Opus 5.5
---
.../minstrel/api/endpoints/MeApi.kt | 9 ++
.../fabledsword/minstrel/auth/AuthStore.kt | 30 ++++
.../minstrel/cache/db/AppDatabase.kt | 16 +-
.../minstrel/cache/db/DatabaseModule.kt | 1 +
.../minstrel/cache/db/dao/AuthSessionDao.kt | 4 +
.../cache/db/dao/CachedMutationDao.kt | 4 +
.../cache/db/entities/AuthSessionEntity.kt | 6 +
.../minstrel/cache/mutations/MutationQueue.kt | 12 ++
.../cache/mutations/MutationReplayer.kt | 22 ++-
.../settings/data/NormalizationPrefs.kt | 55 +++++++
.../settings/data/NormalizationRepository.kt | 67 ++++++++
.../minstrel/settings/ui/NormalizationCard.kt | 143 ++++++++++++++++++
.../settings/ui/NormalizationViewModel.kt | 44 ++++++
.../minstrel/settings/ui/SettingsScreen.kt | 1 +
.../mutations/SupersededToggleIdsTest.kt | 24 +++
.../data/NormalizationRepositoryTest.kt | 90 +++++++++++
internal/api/api.go | 2 +
internal/api/me_normalization.go | 51 +++++++
internal/api/me_normalization_test.go | 61 ++++++++
internal/db/dbq/models.go | 8 +
internal/db/dbq/normalization_prefs.sql.go | 81 ++++++++++
.../0067_user_normalization_prefs.down.sql | 1 +
.../0067_user_normalization_prefs.up.sql | 22 +++
internal/db/queries/normalization_prefs.sql | 16 ++
internal/dbtest/reset.go | 7 +-
internal/library/normalization_prefs.go | 84 ++++++++++
internal/library/normalization_prefs_test.go | 69 +++++++++
web/src/lib/api/normalization.ts | 42 +++++
.../components/NormalizationSetting.svelte | 104 +++++++++++++
.../components/NormalizationSetting.test.ts | 65 ++++++++
web/src/lib/stores/normalization.svelte.ts | 66 ++++++++
web/src/routes/settings/+page.svelte | 3 +
web/src/routes/settings/Appearance.test.ts | 10 ++
web/src/routes/settings/settings.test.ts | 10 ++
34 files changed, 1225 insertions(+), 5 deletions(-)
create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/settings/data/NormalizationPrefs.kt
create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/settings/data/NormalizationRepository.kt
create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/settings/ui/NormalizationCard.kt
create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/settings/ui/NormalizationViewModel.kt
create mode 100644 android/app/src/test/java/com/fabledsword/minstrel/settings/data/NormalizationRepositoryTest.kt
create mode 100644 internal/api/me_normalization.go
create mode 100644 internal/api/me_normalization_test.go
create mode 100644 internal/db/dbq/normalization_prefs.sql.go
create mode 100644 internal/db/migrations/0067_user_normalization_prefs.down.sql
create mode 100644 internal/db/migrations/0067_user_normalization_prefs.up.sql
create mode 100644 internal/db/queries/normalization_prefs.sql
create mode 100644 internal/library/normalization_prefs.go
create mode 100644 internal/library/normalization_prefs_test.go
create mode 100644 web/src/lib/api/normalization.ts
create mode 100644 web/src/lib/components/NormalizationSetting.svelte
create mode 100644 web/src/lib/components/NormalizationSetting.test.ts
create mode 100644 web/src/lib/stores/normalization.svelte.ts
diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/MeApi.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/MeApi.kt
index 6970e767..3962bd2c 100644
--- a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/MeApi.kt
+++ b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/MeApi.kt
@@ -3,6 +3,7 @@ package com.fabledsword.minstrel.api.endpoints
import com.fabledsword.minstrel.models.wire.ListenBrainzStatusWire
import com.fabledsword.minstrel.models.wire.MyProfileWire
import com.fabledsword.minstrel.models.wire.SystemPlaylistsStatusWire
+import com.fabledsword.minstrel.settings.data.NormalizationPrefs
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import retrofit2.http.Body
@@ -59,6 +60,14 @@ interface MeApi {
*/
@PUT("api/me/listenbrainz")
suspend fun setListenBrainz(@Body body: ListenBrainzPutBody): ListenBrainzStatusWire
+
+ /** The caller's loudness-normalization preference, or the defaults if never set. */
+ @GET("api/me/normalization")
+ suspend fun getNormalization(): NormalizationPrefs
+
+ /** Replaces the whole preference; returns what the server stored. */
+ @PUT("api/me/normalization")
+ suspend fun putNormalization(@Body body: NormalizationPrefs): NormalizationPrefs
}
/**
diff --git a/android/app/src/main/java/com/fabledsword/minstrel/auth/AuthStore.kt b/android/app/src/main/java/com/fabledsword/minstrel/auth/AuthStore.kt
index 9ff7bdad..75aa2937 100644
--- a/android/app/src/main/java/com/fabledsword/minstrel/auth/AuthStore.kt
+++ b/android/app/src/main/java/com/fabledsword/minstrel/auth/AuthStore.kt
@@ -4,6 +4,7 @@ import com.fabledsword.minstrel.cache.audiocache.CacheSettings
import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao
import com.fabledsword.minstrel.cache.db.entities.AuthSessionEntity
import com.fabledsword.minstrel.di.ApplicationScope
+import com.fabledsword.minstrel.settings.data.NormalizationPrefs
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async
@@ -77,6 +78,9 @@ class AuthStore @Inject constructor(
private val diagnosticsOptOutState = MutableStateFlow(false)
val diagnosticsOptOut: StateFlow = diagnosticsOptOutState.asStateFlow()
+ private val normalizationState = MutableStateFlow(NormalizationPrefs.DEFAULT)
+ val normalization: StateFlow = normalizationState.asStateFlow()
+
private val json = Json { ignoreUnknownKeys = true }
// Serialises every cookie persist with the one-time hydration, so a
@@ -99,6 +103,7 @@ class AuthStore @Inject constructor(
clientIdState.value = row?.clientId
cacheSettingsState.value = decodeCacheSettings(row?.cacheSettingsJson)
diagnosticsOptOutState.value = row?.diagnosticsOptOut ?: false
+ normalizationState.value = decodeNormalization(row?.normalizationJson)
}
}
}
@@ -110,6 +115,13 @@ class AuthStore @Inject constructor(
}.getOrDefault(CacheSettings.DEFAULT)
}
+ private fun decodeNormalization(raw: String?): NormalizationPrefs {
+ if (raw.isNullOrEmpty()) return NormalizationPrefs.DEFAULT
+ return runCatching {
+ json.decodeFromString(NormalizationPrefs.serializer(), raw)
+ }.getOrDefault(NormalizationPrefs.DEFAULT)
+ }
+
/**
* Suspends until the stored session cookie has been loaded into
* [sessionCookie], or [HYDRATION_DEADLINE_MS] passes (rule 156: a wedged
@@ -187,6 +199,12 @@ class AuthStore @Inject constructor(
scope.launch { persistDiagnosticsOptOut(value) }
}
+ fun setNormalization(value: NormalizationPrefs) {
+ normalizationState.value = value
+ val encoded = json.encodeToString(NormalizationPrefs.serializer(), value)
+ scope.launch { persistNormalization(encoded) }
+ }
+
private suspend fun persistLegacyCookie(value: String?) {
if (dao.get() == null) {
dao.upsert(currentEntity().copy(sessionCookie = value))
@@ -243,6 +261,14 @@ class AuthStore @Inject constructor(
}
}
+ private suspend fun persistNormalization(json: String) {
+ if (dao.get() == null) {
+ dao.upsert(currentEntity().copy(normalizationJson = json))
+ } else {
+ dao.setNormalizationJson(json)
+ }
+ }
+
private fun currentEntity(): AuthSessionEntity = AuthSessionEntity(
id = ROW_ID,
// Never copied into the row: the cookie lives in the vault, and
@@ -257,6 +283,10 @@ class AuthStore @Inject constructor(
cacheSettingsState.value,
),
diagnosticsOptOut = diagnosticsOptOutState.value,
+ normalizationJson = json.encodeToString(
+ NormalizationPrefs.serializer(),
+ normalizationState.value,
+ ),
)
companion object {
diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/AppDatabase.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/AppDatabase.kt
index 49b0f9cf..5573f6bb 100644
--- a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/AppDatabase.kt
+++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/AppDatabase.kt
@@ -3,6 +3,8 @@ package com.fabledsword.minstrel.cache.db
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
+import androidx.room.migration.Migration
+import androidx.sqlite.db.SupportSQLiteDatabase
import com.fabledsword.minstrel.cache.db.dao.AudioCacheIndexDao
import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao
import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao
@@ -65,13 +67,18 @@ import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
AuthSessionEntity::class,
DiagnosticEventEntity::class,
],
+ // 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
+ // row — the server address and theme — and the queued offline writes,
+ // which is too much to lose for one added column.
// v8: + cached_tracks.missing, the server's missing-file mark (#2704),
// so cache-first surfaces stop offering files that cannot stream.
// v7: + diagnostic_events table (M9) and the diagnosticsOptOut column
// 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 = 8,
+ version = 9,
exportSchema = true,
)
@TypeConverters(MinstrelTypeConverters::class)
@@ -92,3 +99,10 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun authSessionDao(): AuthSessionDao
abstract fun diagnosticEventDao(): DiagnosticEventDao
}
+
+/** v8 → v9: add the nullable normalization preference column (#4998). */
+val MIGRATION_8_9: Migration = object : Migration(8, 9) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL("ALTER TABLE auth_session ADD COLUMN normalizationJson TEXT")
+ }
+}
diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/DatabaseModule.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/DatabaseModule.kt
index c5c58df4..883bf004 100644
--- a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/DatabaseModule.kt
+++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/DatabaseModule.kt
@@ -37,6 +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)
.fallbackToDestructiveMigration(dropAllTables = true)
.build()
diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/AuthSessionDao.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/AuthSessionDao.kt
index 3df6b01c..3ca499c7 100644
--- a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/AuthSessionDao.kt
+++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/AuthSessionDao.kt
@@ -46,4 +46,8 @@ interface AuthSessionDao {
/** Partial update: change only the per-device diagnostics opt-out. */
@Query("UPDATE auth_session SET diagnosticsOptOut = :optOut WHERE id = 0")
suspend fun setDiagnosticsOptOut(optOut: Boolean)
+
+ /** Partial update: change only the serialized normalization preference. */
+ @Query("UPDATE auth_session SET normalizationJson = :json WHERE id = 0")
+ suspend fun setNormalizationJson(json: String?)
}
diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedMutationDao.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedMutationDao.kt
index 60fd62d9..a13329a6 100644
--- a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedMutationDao.kt
+++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedMutationDao.kt
@@ -31,4 +31,8 @@ interface CachedMutationDao {
@Query("DELETE FROM cached_mutations")
suspend fun clear()
+
+ /** Whether a write of [kind] is still waiting to be replayed. */
+ @Query("SELECT EXISTS(SELECT 1 FROM cached_mutations WHERE kind = :kind)")
+ suspend fun hasPending(kind: String): Boolean
}
diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/AuthSessionEntity.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/AuthSessionEntity.kt
index 6b8456c9..578f6e88 100644
--- a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/AuthSessionEntity.kt
+++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/AuthSessionEntity.kt
@@ -43,4 +43,10 @@ data class AuthSessionEntity(
* choice lives here. Default false = honor the account flag.
*/
val diagnosticsOptOut: Boolean = false,
+ /**
+ * JSON-encoded NormalizationPrefs (settings/data), the last value seen
+ * from the server or set here (M464 #4998). Null = never fetched; the
+ * defaults apply. Kept so offline playback still levels.
+ */
+ val normalizationJson: String? = null,
)
diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/mutations/MutationQueue.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/mutations/MutationQueue.kt
index 84a0bdf0..be70247a 100644
--- a/android/app/src/main/java/com/fabledsword/minstrel/cache/mutations/MutationQueue.kt
+++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/mutations/MutationQueue.kt
@@ -2,6 +2,7 @@ package com.fabledsword.minstrel.cache.mutations
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
+import com.fabledsword.minstrel.settings.data.NormalizationPrefs
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
@@ -41,6 +42,11 @@ object MutationKind {
// an undo collapses to the latest intent instead of replaying as two
// opposed calls whose order decides the outcome.
const val SUGGESTION_SNOOZE_TOGGLE: String = "suggestion_snooze_toggle"
+
+ // M464 #4998 loudness-normalization preference. The payload is the whole
+ // preference, a target state like the toggles above, so queued changes
+ // collapse to the last one and an older one can never be replayed last.
+ const val NORMALIZATION_SET: String = "normalization_set"
}
/**
@@ -177,6 +183,12 @@ class MutationQueue @Inject constructor(
),
)
+ /** Queues the user's whole normalization preference for replay. */
+ suspend fun enqueueNormalizationSet(prefs: NormalizationPrefs): Long = insertUserDriven(
+ MutationKind.NORMALIZATION_SET,
+ json.encodeToString(NormalizationPrefs.serializer(), prefs),
+ )
+
suspend fun enqueueRequestCancel(requestId: String): Long = insertUserDriven(
MutationKind.REQUEST_CANCEL,
json.encodeToString(
diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/mutations/MutationReplayer.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/mutations/MutationReplayer.kt
index ef54a76a..55dd4a24 100644
--- a/android/app/src/main/java/com/fabledsword/minstrel/cache/mutations/MutationReplayer.kt
+++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/mutations/MutationReplayer.kt
@@ -7,6 +7,7 @@ import com.fabledsword.minstrel.api.endpoints.DiscoverApi
import com.fabledsword.minstrel.api.endpoints.EventsApi
import com.fabledsword.minstrel.api.endpoints.FlagRequest
import com.fabledsword.minstrel.api.endpoints.LikesApi
+import com.fabledsword.minstrel.api.endpoints.MeApi
import com.fabledsword.minstrel.api.endpoints.PlaybackErrorReportRequest
import com.fabledsword.minstrel.api.endpoints.PlaybackErrorsApi
import com.fabledsword.minstrel.api.endpoints.PlaylistsApi
@@ -22,6 +23,7 @@ import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.likes.data.LikesRepository
+import com.fabledsword.minstrel.settings.data.NormalizationPrefs
import com.fabledsword.minstrel.models.wire.CreateRequestBody
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.distinctUntilChanged
@@ -79,6 +81,7 @@ class MutationReplayer @Inject constructor(
private val eventsApi: EventsApi = retrofit.create()
private val requestsApi: RequestsApi = retrofit.create()
private val playbackErrorsApi: PlaybackErrorsApi = retrofit.create()
+ private val meApi: MeApi = retrofit.create()
private val mutex = Mutex()
@@ -166,6 +169,7 @@ class MutationReplayer @Inject constructor(
MutationKind.REQUEST_CANCEL -> dispatchRequestCancel(row.payload)
MutationKind.PLAYBACK_ERROR_REPORT -> dispatchPlaybackErrorReport(row.payload)
MutationKind.SUGGESTION_SNOOZE_TOGGLE -> dispatchSuggestionSnoozeToggle(row.payload)
+ MutationKind.NORMALIZATION_SET -> dispatchNormalizationSet(row.payload)
// Unknown kind — drop so a stale schema entry can't wedge the queue.
else -> Outcome.DROP
}
@@ -279,6 +283,16 @@ class MutationReplayer @Inject constructor(
return Outcome.SENT
}
+ /**
+ * Sends the queued normalization preference. The device already shows
+ * it, so the server's echo is not written back: a change made since the
+ * row was queued would be a newer row, and the collapse keeps only that.
+ */
+ private suspend fun dispatchNormalizationSet(payload: String): Outcome {
+ meApi.putNormalization(json.decodeFromString(NormalizationPrefs.serializer(), payload))
+ return Outcome.SENT
+ }
+
private suspend fun dispatchPlaybackErrorReport(payload: String): Outcome {
val decoded = json.decodeFromString(PlaybackErrorReportPayload.serializer(), payload)
playbackErrorsApi.report(
@@ -303,7 +317,8 @@ class MutationReplayer @Inject constructor(
/**
* Row ids of desired-state toggles superseded by a later toggle for the same
* entity. Applies to every kind whose payload encodes a TARGET state rather
- * than an action — like-toggles and suggestion snoozes (#2374) — because
+ * than an action — like-toggles, suggestion snoozes (#2374) and the
+ * normalization preference (#4998) — because
* replaying a stale one last would invert the final state.
*
* Top-level and pure so it can be unit-tested without standing up a Retrofit
@@ -340,5 +355,10 @@ private fun toggleKeyOf(row: CachedMutationEntity, json: Json): String? = when (
json.decodeFromString(SuggestionSnoozeTogglePayload.serializer(), row.payload)
}.getOrNull()?.let { "${row.kind}:${it.mbid}" }
+ // One preference per user, so every normalization row shares one key.
+ MutationKind.NORMALIZATION_SET -> runCatching {
+ json.decodeFromString(NormalizationPrefs.serializer(), row.payload)
+ }.getOrNull()?.let { row.kind }
+
else -> null
}
diff --git a/android/app/src/main/java/com/fabledsword/minstrel/settings/data/NormalizationPrefs.kt b/android/app/src/main/java/com/fabledsword/minstrel/settings/data/NormalizationPrefs.kt
new file mode 100644
index 00000000..ea9d6a1a
--- /dev/null
+++ b/android/app/src/main/java/com/fabledsword/minstrel/settings/data/NormalizationPrefs.kt
@@ -0,0 +1,55 @@
+package com.fabledsword.minstrel.settings.data
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+/**
+ * The user's loudness-normalization preference (M464 #4998). The server
+ * holds it, so this device, the web player and casts all level the same
+ * way; a copy is kept on the device so offline playback still applies it.
+ * Field names and values match `GET/PUT /api/me/normalization`.
+ *
+ * No constructor defaults, on purpose: the app's Json leaves out any field
+ * equal to its default (encodeDefaults is off), and the server refuses a PUT
+ * missing a field. Without defaults every field is always sent.
+ */
+@Serializable
+data class NormalizationPrefs(
+ val mode: NormalizationMode,
+ @SerialName("target_lufs") val targetLufs: Int,
+ val boost: NormalizationBoost,
+) {
+ companion object {
+ /** The targets the server accepts, quietest first. */
+ val TARGETS: List = listOf(-18, -16, -14)
+
+ /** Must match library.DefaultNormalizationPrefs on the server. */
+ val DEFAULT: NormalizationPrefs = NormalizationPrefs(
+ mode = NormalizationMode.AUTO,
+ targetLufs = -18,
+ boost = NormalizationBoost.HEADROOM,
+ )
+ }
+}
+
+@Serializable
+enum class NormalizationMode {
+ /** Tracks play at their mastered volume. */
+ @SerialName("off") OFF,
+
+ /** Album gain while an album plays in order, track gain otherwise. */
+ @SerialName("auto") AUTO,
+
+ @SerialName("track") TRACK,
+
+ @SerialName("album") ALBUM,
+}
+
+@Serializable
+enum class NormalizationBoost {
+ /** Quiet tracks are raised only as far as their true peak allows. */
+ @SerialName("headroom") HEADROOM,
+
+ /** Quiet tracks are raised all the way and a limiter holds the peaks. */
+ @SerialName("limiter") LIMITER,
+}
diff --git a/android/app/src/main/java/com/fabledsword/minstrel/settings/data/NormalizationRepository.kt b/android/app/src/main/java/com/fabledsword/minstrel/settings/data/NormalizationRepository.kt
new file mode 100644
index 00000000..4e6f0727
--- /dev/null
+++ b/android/app/src/main/java/com/fabledsword/minstrel/settings/data/NormalizationRepository.kt
@@ -0,0 +1,67 @@
+package com.fabledsword.minstrel.settings.data
+
+import com.fabledsword.minstrel.api.endpoints.MeApi
+import com.fabledsword.minstrel.auth.AuthStore
+import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
+import com.fabledsword.minstrel.cache.mutations.MutationKind
+import com.fabledsword.minstrel.cache.mutations.MutationQueue
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.flow.StateFlow
+import retrofit2.Retrofit
+import retrofit2.create
+import timber.log.Timber
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * The user's loudness-normalization preference (M464 #4998). The device's
+ * copy lives in [AuthStore] so playback reads it offline; the server's copy
+ * is what other devices and casts use.
+ *
+ * Writes follow rule 100: the device shows the change at once, the PUT is
+ * best effort, and a failed PUT is queued for the MutationReplayer.
+ */
+@Singleton
+class NormalizationRepository @Inject constructor(
+ retrofit: Retrofit,
+ private val authStore: AuthStore,
+ private val mutationQueue: MutationQueue,
+ private val mutationDao: CachedMutationDao,
+) {
+ private val api: MeApi = retrofit.create()
+
+ val prefs: StateFlow = authStore.normalization
+
+ /**
+ * Takes the server's value, unless a change made here is still queued:
+ * the server has not seen it yet, and taking its older value would undo
+ * the change on screen until the replay landed. Throws on a network
+ * failure; the device's copy then stands.
+ */
+ suspend fun refresh() {
+ val server = api.getNormalization()
+ if (mutationDao.hasPending(MutationKind.NORMALIZATION_SET)) return
+ authStore.setNormalization(server)
+ }
+
+ suspend fun set(next: NormalizationPrefs) {
+ authStore.setNormalization(next)
+ // An earlier change still queued would be replayed after this PUT
+ // and undo it. Queue behind it instead: the replayer sends only the
+ // newest queued preference.
+ if (mutationDao.hasPending(MutationKind.NORMALIZATION_SET)) {
+ mutationQueue.enqueueNormalizationSet(next)
+ return
+ }
+ try {
+ api.putNormalization(next)
+ } catch (e: CancellationException) {
+ throw e
+ } catch (
+ @Suppress("TooGenericExceptionCaught") e: Throwable,
+ ) {
+ Timber.i(e, "normalization: PUT failed; queued for replay")
+ mutationQueue.enqueueNormalizationSet(next)
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/NormalizationCard.kt b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/NormalizationCard.kt
new file mode 100644
index 00000000..bcb5ffd2
--- /dev/null
+++ b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/NormalizationCard.kt
@@ -0,0 +1,143 @@
+package com.fabledsword.minstrel.settings.ui
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.ElevatedCard
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.SegmentedButton
+import androidx.compose.material3.SegmentedButtonDefaults
+import androidx.compose.material3.SingleChoiceSegmentedButtonRow
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.fabledsword.minstrel.settings.data.NormalizationBoost
+import com.fabledsword.minstrel.settings.data.NormalizationMode
+import com.fabledsword.minstrel.settings.data.NormalizationPrefs
+
+/**
+ * Volume leveling (M464 #4998). The choice is stored on the server, so the
+ * web player and casts follow it too.
+ */
+@Composable
+fun NormalizationCard(viewModel: NormalizationViewModel = hiltViewModel()) {
+ val prefs by viewModel.prefs.collectAsStateWithLifecycle()
+ NormalizationCardContent(prefs = prefs, onChange = viewModel::update)
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+internal fun NormalizationCardContent(
+ prefs: NormalizationPrefs,
+ onChange: (NormalizationPrefs) -> Unit,
+) {
+ ElevatedCard(modifier = Modifier.fillMaxWidth()) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Text(
+ text = "Volume leveling",
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ Hint(modeHint(prefs.mode))
+ val modes = NormalizationMode.entries
+ SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
+ modes.forEachIndexed { index, mode ->
+ SegmentedButton(
+ selected = mode == prefs.mode,
+ onClick = { onChange(prefs.copy(mode = mode)) },
+ shape = SegmentedButtonDefaults.itemShape(
+ index = index,
+ count = modes.size,
+ ),
+ ) { Text(modeLabel(mode)) }
+ }
+ }
+ if (prefs.mode != NormalizationMode.OFF) {
+ TargetRow(prefs = prefs, onChange = onChange)
+ BoostRow(prefs = prefs, onChange = onChange)
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun TargetRow(prefs: NormalizationPrefs, onChange: (NormalizationPrefs) -> Unit) {
+ Text(
+ text = "Target loudness",
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ val targets = NormalizationPrefs.TARGETS
+ SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
+ targets.forEachIndexed { index, lufs ->
+ SegmentedButton(
+ selected = lufs == prefs.targetLufs,
+ onClick = { onChange(prefs.copy(targetLufs = lufs)) },
+ shape = SegmentedButtonDefaults.itemShape(
+ index = index,
+ count = targets.size,
+ ),
+ ) { Text("$lufs LUFS") }
+ }
+ }
+}
+
+@Composable
+private fun BoostRow(prefs: NormalizationPrefs, onChange: (NormalizationPrefs) -> Unit) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = "Boost quiet tracks fully",
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ Hint("A limiter catches the peaks.")
+ }
+ Spacer(Modifier.size(12.dp))
+ Switch(
+ checked = prefs.boost == NormalizationBoost.LIMITER,
+ onCheckedChange = { on ->
+ val boost = if (on) NormalizationBoost.LIMITER else NormalizationBoost.HEADROOM
+ onChange(prefs.copy(boost = boost))
+ },
+ )
+ }
+}
+
+@Composable
+private fun Hint(text: String) {
+ Text(
+ text = text,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+}
+
+private fun modeLabel(mode: NormalizationMode): String = when (mode) {
+ NormalizationMode.OFF -> "Off"
+ NormalizationMode.AUTO -> "Auto"
+ NormalizationMode.TRACK -> "Track"
+ NormalizationMode.ALBUM -> "Album"
+}
+
+private fun modeHint(mode: NormalizationMode): String = when (mode) {
+ NormalizationMode.OFF -> "Tracks play at their mastered volume."
+ NormalizationMode.AUTO -> "Album gain for whole albums, track gain otherwise."
+ NormalizationMode.TRACK -> "Every track at the same loudness."
+ NormalizationMode.ALBUM -> "Albums keep their own quiet and loud tracks."
+}
diff --git a/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/NormalizationViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/NormalizationViewModel.kt
new file mode 100644
index 00000000..7bcf3928
--- /dev/null
+++ b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/NormalizationViewModel.kt
@@ -0,0 +1,44 @@
+package com.fabledsword.minstrel.settings.ui
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.fabledsword.minstrel.settings.data.NormalizationPrefs
+import com.fabledsword.minstrel.settings.data.NormalizationRepository
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.launch
+import timber.log.Timber
+import javax.inject.Inject
+
+/**
+ * Backs the Volume leveling card. Shows the device's copy straight away and
+ * refreshes it from the server on open, so a change made on the web shows
+ * here too.
+ */
+@HiltViewModel
+class NormalizationViewModel @Inject constructor(
+ private val repository: NormalizationRepository,
+) : ViewModel() {
+
+ val prefs: StateFlow = repository.prefs
+
+ init {
+ viewModelScope.launch {
+ try {
+ repository.refresh()
+ } catch (e: CancellationException) {
+ throw e
+ } catch (
+ @Suppress("TooGenericExceptionCaught") e: Throwable,
+ ) {
+ // Offline: the device's copy stands.
+ Timber.i(e, "normalization: refresh failed")
+ }
+ }
+ }
+
+ fun update(next: NormalizationPrefs) {
+ viewModelScope.launch { repository.set(next) }
+ }
+}
diff --git a/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/SettingsScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/SettingsScreen.kt
index 52fa6333..a09f93fa 100644
--- a/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/SettingsScreen.kt
+++ b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/SettingsScreen.kt
@@ -161,6 +161,7 @@ private fun SettingsList(
onToggle = onToggleDiagnostics,
)
}
+ NormalizationCard()
AppearanceCard(themeMode = themeMode, onPick = onPickTheme)
StorageCard()
AboutCard()
diff --git a/android/app/src/test/java/com/fabledsword/minstrel/cache/mutations/SupersededToggleIdsTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/cache/mutations/SupersededToggleIdsTest.kt
index b280ab80..c54356c5 100644
--- a/android/app/src/test/java/com/fabledsword/minstrel/cache/mutations/SupersededToggleIdsTest.kt
+++ b/android/app/src/test/java/com/fabledsword/minstrel/cache/mutations/SupersededToggleIdsTest.kt
@@ -1,6 +1,8 @@
package com.fabledsword.minstrel.cache.mutations
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
+import com.fabledsword.minstrel.settings.data.NormalizationMode
+import com.fabledsword.minstrel.settings.data.NormalizationPrefs
import kotlinx.serialization.json.Json
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
@@ -129,4 +131,26 @@ class SupersededToggleIdsTest {
fun `an empty queue collapses nothing`() {
assertTrue(supersededToggleIds(emptyList(), json).isEmpty())
}
+
+ private fun normalizationRow(id: Long, mode: NormalizationMode) = CachedMutationEntity(
+ id = id,
+ kind = MutationKind.NORMALIZATION_SET,
+ payload = json.encodeToString(
+ NormalizationPrefs.serializer(),
+ NormalizationPrefs.DEFAULT.copy(mode = mode),
+ ),
+ )
+
+ // There is one preference per user, so any two queued changes to it
+ // collapse, and only the newest is sent (#4998).
+ @Test
+ fun `only the newest queued normalization change survives`() {
+ val rows = listOf(
+ normalizationRow(1, NormalizationMode.TRACK),
+ snoozeRow(2, "mb-a", desiredSnoozed = true),
+ normalizationRow(3, NormalizationMode.OFF),
+ normalizationRow(4, NormalizationMode.ALBUM),
+ )
+ assertEquals(setOf(1L, 3L), supersededToggleIds(rows, json))
+ }
}
diff --git a/android/app/src/test/java/com/fabledsword/minstrel/settings/data/NormalizationRepositoryTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/settings/data/NormalizationRepositoryTest.kt
new file mode 100644
index 00000000..6853887d
--- /dev/null
+++ b/android/app/src/test/java/com/fabledsword/minstrel/settings/data/NormalizationRepositoryTest.kt
@@ -0,0 +1,90 @@
+package com.fabledsword.minstrel.settings.data
+
+import com.fabledsword.minstrel.api.endpoints.MeApi
+import com.fabledsword.minstrel.auth.AuthStore
+import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
+import com.fabledsword.minstrel.cache.mutations.MutationKind
+import com.fabledsword.minstrel.cache.mutations.MutationQueue
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.test.runTest
+import org.junit.jupiter.api.Test
+import retrofit2.Retrofit
+import java.io.IOException
+
+/**
+ * The normalization preference is written offline-first (rule 100): the
+ * device shows the change at once, and a change the server has not seen
+ * yet is never overwritten or reordered.
+ */
+class NormalizationRepositoryTest {
+ private val api: MeApi = mockk()
+ private val authStore: AuthStore = mockk(relaxed = true) {
+ every { normalization } returns MutableStateFlow(NormalizationPrefs.DEFAULT)
+ }
+ private val queue: MutationQueue = mockk(relaxed = true)
+ private val dao: CachedMutationDao = mockk()
+ private val retrofit: Retrofit = mockk {
+ every { create(MeApi::class.java) } returns api
+ }
+ private val repo = NormalizationRepository(retrofit, authStore, queue, dao)
+
+ private val album = NormalizationPrefs.DEFAULT.copy(mode = NormalizationMode.ALBUM)
+
+ @Test
+ fun `a change is shown at once and sent`() = runTest {
+ coEvery { dao.hasPending(MutationKind.NORMALIZATION_SET) } returns false
+ coEvery { api.putNormalization(album) } returns album
+
+ repo.set(album)
+
+ verify { authStore.setNormalization(album) }
+ coVerify { api.putNormalization(album) }
+ coVerify(exactly = 0) { queue.enqueueNormalizationSet(any()) }
+ }
+
+ @Test
+ fun `a change that cannot reach the server is queued`() = runTest {
+ coEvery { dao.hasPending(MutationKind.NORMALIZATION_SET) } returns false
+ coEvery { api.putNormalization(album) } throws IOException("offline")
+
+ repo.set(album)
+
+ verify { authStore.setNormalization(album) }
+ coVerify { queue.enqueueNormalizationSet(album) }
+ }
+
+ @Test
+ fun `a change behind a queued one queues too, so the older one cannot land last`() = runTest {
+ coEvery { dao.hasPending(MutationKind.NORMALIZATION_SET) } returns true
+
+ repo.set(album)
+
+ coVerify(exactly = 0) { api.putNormalization(any()) }
+ coVerify { queue.enqueueNormalizationSet(album) }
+ }
+
+ @Test
+ fun `refresh takes the server value`() = runTest {
+ coEvery { api.getNormalization() } returns album
+ coEvery { dao.hasPending(MutationKind.NORMALIZATION_SET) } returns false
+
+ repo.refresh()
+
+ verify { authStore.setNormalization(album) }
+ }
+
+ @Test
+ fun `refresh leaves a queued change on screen`() = runTest {
+ coEvery { api.getNormalization() } returns NormalizationPrefs.DEFAULT
+ coEvery { dao.hasPending(MutationKind.NORMALIZATION_SET) } returns true
+
+ repo.refresh()
+
+ verify(exactly = 0) { authStore.setNormalization(any()) }
+ }
+}
diff --git a/internal/api/api.go b/internal/api/api.go
index a495c2fa..03e539bc 100644
--- a/internal/api/api.go
+++ b/internal/api/api.go
@@ -109,6 +109,8 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
authed.Put("/me/password", h.handleChangePassword)
authed.Put("/me/profile", h.handleUpdateMyProfile)
authed.Put("/me/timezone", h.handlePutTimezone)
+ authed.Get("/me/normalization", h.handleGetMyNormalization)
+ authed.Put("/me/normalization", h.handlePutMyNormalization)
authed.Post("/me/api-token", h.handleRegenerateMyAPIToken)
authed.Get("/me/subsonic-password", h.handleGetMySubsonicPassword)
authed.Post("/me/subsonic-password", h.handleGenerateMySubsonicPassword)
diff --git a/internal/api/me_normalization.go b/internal/api/me_normalization.go
new file mode 100644
index 00000000..4b41b9fe
--- /dev/null
+++ b/internal/api/me_normalization.go
@@ -0,0 +1,51 @@
+package api
+
+import (
+ "encoding/json"
+ "errors"
+ "net/http"
+
+ "git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
+ "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
+ "git.fabledsword.com/bvandeusen/minstrel/internal/library"
+)
+
+// GET /api/me/normalization returns the caller's loudness-normalization
+// preference, or the defaults when they have never set one (M464 #4998).
+func (h *handlers) handleGetMyNormalization(w http.ResponseWriter, r *http.Request) {
+ caller, ok := requireUser(w, r)
+ if !ok {
+ return
+ }
+ p, err := library.LoadNormalizationPrefs(r.Context(), dbq.New(h.pool), caller.ID)
+ if err != nil {
+ writeErrWithLog(w, h.logger, "normalization: load", apierror.InternalMsg("lookup failed", err))
+ return
+ }
+ writeJSON(w, http.StatusOK, p)
+}
+
+// PUT /api/me/normalization replaces the caller's preference with the whole
+// body. Every field is required, so a client replaying a queued change
+// offline sends the full state it showed the user, and the last write wins.
+func (h *handlers) handlePutMyNormalization(w http.ResponseWriter, r *http.Request) {
+ caller, ok := requireUser(w, r)
+ if !ok {
+ return
+ }
+ var body library.NormalizationPrefs
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+ writeErr(w, apierror.BadRequest("bad_body", "invalid JSON body"))
+ return
+ }
+ p, err := library.SaveNormalizationPrefs(r.Context(), dbq.New(h.pool), caller.ID, body)
+ if errors.Is(err, library.ErrNormalizationPrefsInvalid) {
+ writeErr(w, apierror.BadRequest("invalid_normalization", err.Error()))
+ return
+ }
+ if err != nil {
+ writeErrWithLog(w, h.logger, "normalization: save", apierror.InternalMsg("save failed", err))
+ return
+ }
+ writeJSON(w, http.StatusOK, p)
+}
diff --git a/internal/api/me_normalization_test.go b/internal/api/me_normalization_test.go
new file mode 100644
index 00000000..0058dfab
--- /dev/null
+++ b/internal/api/me_normalization_test.go
@@ -0,0 +1,61 @@
+package api
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/go-chi/chi/v5"
+
+ "git.fabledsword.com/bvandeusen/minstrel/internal/library"
+)
+
+func TestMyNormalization_GetPut(t *testing.T) {
+ h, pool := testHandlers(t)
+ user := seedUser(t, pool, "norm1", "pw", false)
+ r := chi.NewRouter()
+ r.Get("/api/me/normalization", h.handleGetMyNormalization)
+ r.Put("/api/me/normalization", h.handlePutMyNormalization)
+ do := func(method, body string) (int, library.NormalizationPrefs) {
+ t.Helper()
+ req := withUser(httptest.NewRequest(method, "/api/me/normalization", bytes.NewBufferString(body)), user)
+ rec := httptest.NewRecorder()
+ r.ServeHTTP(rec, req)
+ var p library.NormalizationPrefs
+ if rec.Code == http.StatusOK {
+ if err := json.Unmarshal(rec.Body.Bytes(), &p); err != nil {
+ t.Fatalf("decode %s: %v", rec.Body.String(), err)
+ }
+ }
+ return rec.Code, p
+ }
+
+ if code, p := do(http.MethodGet, ""); code != http.StatusOK || p != library.DefaultNormalizationPrefs {
+ t.Fatalf("GET before any save = %d %+v, want 200 with the defaults", code, p)
+ }
+
+ want := library.NormalizationPrefs{Mode: "album", TargetLUFS: -16, Boost: "limiter"}
+ if code, p := do(http.MethodPut, `{"mode":"album","target_lufs":-16,"boost":"limiter"}`); code != http.StatusOK || p != want {
+ t.Fatalf("PUT = %d %+v, want 200 %+v", code, p, want)
+ }
+ if code, p := do(http.MethodGet, ""); code != http.StatusOK || p != want {
+ t.Errorf("GET after PUT = %d %+v, want %+v", code, p, want)
+ }
+
+ // Bad values and partial bodies are refused, and the stored prefs stand.
+ for _, body := range []string{
+ `{"mode":"loud","target_lufs":-16,"boost":"limiter"}`,
+ `{"mode":"album","target_lufs":-23,"boost":"limiter"}`,
+ `{"mode":"album","target_lufs":-16}`,
+ `not json`,
+ } {
+ if code, _ := do(http.MethodPut, body); code != http.StatusBadRequest {
+ t.Errorf("PUT %s = %d, want 400", body, code)
+ }
+ }
+ if _, p := do(http.MethodGet, ""); p != want {
+ t.Errorf("a refused PUT changed the prefs to %+v", p)
+ }
+}
diff --git a/internal/db/dbq/models.go b/internal/db/dbq/models.go
index fd55ee9c..90a6fb80 100644
--- a/internal/db/dbq/models.go
+++ b/internal/db/dbq/models.go
@@ -784,6 +784,14 @@ type UserInvite struct {
RedeemedBy pgtype.UUID
}
+type UserNormalizationPref struct {
+ UserID pgtype.UUID
+ Mode string
+ TargetLufs int16
+ Boost string
+ UpdatedAt pgtype.Timestamptz
+}
+
type YouMightLikeAlbum struct {
UserID pgtype.UUID
AlbumID pgtype.UUID
diff --git a/internal/db/dbq/normalization_prefs.sql.go b/internal/db/dbq/normalization_prefs.sql.go
new file mode 100644
index 00000000..332edb96
--- /dev/null
+++ b/internal/db/dbq/normalization_prefs.sql.go
@@ -0,0 +1,81 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.31.1
+// source: normalization_prefs.sql
+
+package dbq
+
+import (
+ "context"
+
+ "github.com/jackc/pgx/v5/pgtype"
+)
+
+const getNormalizationPrefs = `-- name: GetNormalizationPrefs :one
+SELECT mode, target_lufs, boost, updated_at
+ FROM user_normalization_prefs
+ WHERE user_id = $1
+`
+
+type GetNormalizationPrefsRow struct {
+ Mode string
+ TargetLufs int16
+ Boost string
+ UpdatedAt pgtype.Timestamptz
+}
+
+func (q *Queries) GetNormalizationPrefs(ctx context.Context, userID pgtype.UUID) (GetNormalizationPrefsRow, error) {
+ row := q.db.QueryRow(ctx, getNormalizationPrefs, userID)
+ var i GetNormalizationPrefsRow
+ err := row.Scan(
+ &i.Mode,
+ &i.TargetLufs,
+ &i.Boost,
+ &i.UpdatedAt,
+ )
+ return i, err
+}
+
+const upsertNormalizationPrefs = `-- name: UpsertNormalizationPrefs :one
+INSERT INTO user_normalization_prefs (user_id, mode, target_lufs, boost)
+VALUES ($1, $2, $3, $4)
+ON CONFLICT (user_id) DO UPDATE SET
+ mode = EXCLUDED.mode,
+ target_lufs = EXCLUDED.target_lufs,
+ boost = EXCLUDED.boost,
+ updated_at = now()
+RETURNING mode, target_lufs, boost, updated_at
+`
+
+type UpsertNormalizationPrefsParams struct {
+ UserID pgtype.UUID
+ Mode string
+ TargetLufs int16
+ Boost string
+}
+
+type UpsertNormalizationPrefsRow struct {
+ Mode string
+ TargetLufs int16
+ Boost string
+ UpdatedAt pgtype.Timestamptz
+}
+
+// Whole-row write from either client; the CHECKs in migration 0067 back the
+// handler's own validation.
+func (q *Queries) UpsertNormalizationPrefs(ctx context.Context, arg UpsertNormalizationPrefsParams) (UpsertNormalizationPrefsRow, error) {
+ row := q.db.QueryRow(ctx, upsertNormalizationPrefs,
+ arg.UserID,
+ arg.Mode,
+ arg.TargetLufs,
+ arg.Boost,
+ )
+ var i UpsertNormalizationPrefsRow
+ err := row.Scan(
+ &i.Mode,
+ &i.TargetLufs,
+ &i.Boost,
+ &i.UpdatedAt,
+ )
+ return i, err
+}
diff --git a/internal/db/migrations/0067_user_normalization_prefs.down.sql b/internal/db/migrations/0067_user_normalization_prefs.down.sql
new file mode 100644
index 00000000..650e65d7
--- /dev/null
+++ b/internal/db/migrations/0067_user_normalization_prefs.down.sql
@@ -0,0 +1 @@
+DROP TABLE IF EXISTS user_normalization_prefs;
diff --git a/internal/db/migrations/0067_user_normalization_prefs.up.sql b/internal/db/migrations/0067_user_normalization_prefs.up.sql
new file mode 100644
index 00000000..30163933
--- /dev/null
+++ b/internal/db/migrations/0067_user_normalization_prefs.up.sql
@@ -0,0 +1,22 @@
+-- Each user's loudness-normalization preference (M464 #4998). It is kept on
+-- the server, not per device, so the web player, the Android app and the
+-- leveled stream for UPnP/Sonos all apply the same one.
+--
+-- mode: off, auto (album gain while an album plays in order, otherwise
+-- track gain), track or album.
+-- target: the loudness tracks are leveled to, in LUFS.
+-- boost: headroom raises quiet tracks only as far as their true peak
+-- allows; limiter raises them all the way and limits the peaks.
+--
+-- No row means the defaults, which are the column defaults here and
+-- library.DefaultNormalizationPrefs in Go (a test holds them equal).
+CREATE TABLE user_normalization_prefs (
+ user_id uuid PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
+ mode text NOT NULL DEFAULT 'auto'
+ CHECK (mode IN ('off', 'auto', 'track', 'album')),
+ target_lufs smallint NOT NULL DEFAULT -18
+ CHECK (target_lufs IN (-18, -16, -14)),
+ boost text NOT NULL DEFAULT 'headroom'
+ CHECK (boost IN ('headroom', 'limiter')),
+ updated_at timestamptz NOT NULL DEFAULT now()
+);
diff --git a/internal/db/queries/normalization_prefs.sql b/internal/db/queries/normalization_prefs.sql
new file mode 100644
index 00000000..ebe3eb30
--- /dev/null
+++ b/internal/db/queries/normalization_prefs.sql
@@ -0,0 +1,16 @@
+-- name: GetNormalizationPrefs :one
+SELECT mode, target_lufs, boost, updated_at
+ FROM user_normalization_prefs
+ WHERE user_id = $1;
+
+-- name: UpsertNormalizationPrefs :one
+-- Whole-row write from either client; the CHECKs in migration 0067 back the
+-- handler's own validation.
+INSERT INTO user_normalization_prefs (user_id, mode, target_lufs, boost)
+VALUES (sqlc.arg(user_id), sqlc.arg(mode), sqlc.arg(target_lufs), sqlc.arg(boost))
+ON CONFLICT (user_id) DO UPDATE SET
+ mode = EXCLUDED.mode,
+ target_lufs = EXCLUDED.target_lufs,
+ boost = EXCLUDED.boost,
+ updated_at = now()
+RETURNING mode, target_lufs, boost, updated_at;
diff --git a/internal/dbtest/reset.go b/internal/dbtest/reset.go
index 24507d92..abcb16ae 100644
--- a/internal/dbtest/reset.go
+++ b/internal/dbtest/reset.go
@@ -90,9 +90,10 @@ var dataTables = []string{
"duplicate_group_members", // M400
"duplicate_groups",
"duplicate_sweeps",
- "track_fingerprints", // M400
- "track_loudness", // M464
- "album_loudness", // M464
+ "track_fingerprints", // M400
+ "track_loudness", // M464
+ "album_loudness", // M464
+ "user_normalization_prefs", // M464
"tracks",
"albums",
"artists",
diff --git a/internal/library/normalization_prefs.go b/internal/library/normalization_prefs.go
new file mode 100644
index 00000000..22e68cc3
--- /dev/null
+++ b/internal/library/normalization_prefs.go
@@ -0,0 +1,84 @@
+package library
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "slices"
+
+ "github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgtype"
+
+ "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
+)
+
+// NormalizationPrefs is one user's loudness-normalization preference (M464
+// #4998). It lives on the server so every player applies the same one: the
+// web player and the Android app read it, and the leveled stream for
+// UPnP/Sonos (#5001) renders with it.
+type NormalizationPrefs struct {
+ // Mode is off, auto, track or album. Auto uses album gain while an album
+ // plays in order and track gain otherwise.
+ Mode string `json:"mode"`
+ // TargetLUFS is the loudness tracks are leveled to.
+ TargetLUFS int16 `json:"target_lufs"`
+ // Boost is how a track quieter than the target is raised: headroom stops
+ // at its true peak, limiter goes all the way and limits the peaks.
+ Boost string `json:"boost"`
+}
+
+// The accepted values. Migration 0067's CHECKs hold the same lists.
+var (
+ NormalizationModes = []string{"off", "auto", "track", "album"}
+ NormalizationTargets = []int16{-18, -16, -14}
+ NormalizationBoosts = []string{"headroom", "limiter"}
+)
+
+// DefaultNormalizationPrefs applies to a user who has never chosen. It must
+// equal migration 0067's column defaults; a test holds them together.
+var DefaultNormalizationPrefs = NormalizationPrefs{Mode: "auto", TargetLUFS: -18, Boost: "headroom"}
+
+// ErrNormalizationPrefsInvalid is returned for a value outside the lists.
+var ErrNormalizationPrefsInvalid = errors.New("normalization preference out of range")
+
+// Validate reports the first field holding a value the server does not
+// accept.
+func (p NormalizationPrefs) Validate() error {
+ switch {
+ case !slices.Contains(NormalizationModes, p.Mode):
+ return fmt.Errorf("%w: mode %q", ErrNormalizationPrefsInvalid, p.Mode)
+ case !slices.Contains(NormalizationTargets, p.TargetLUFS):
+ return fmt.Errorf("%w: target_lufs %d", ErrNormalizationPrefsInvalid, p.TargetLUFS)
+ case !slices.Contains(NormalizationBoosts, p.Boost):
+ return fmt.Errorf("%w: boost %q", ErrNormalizationPrefsInvalid, p.Boost)
+ }
+ return nil
+}
+
+// LoadNormalizationPrefs returns the user's preference, or the defaults if
+// they have never set one.
+func LoadNormalizationPrefs(ctx context.Context, q *dbq.Queries, userID pgtype.UUID) (NormalizationPrefs, error) {
+ row, err := q.GetNormalizationPrefs(ctx, userID)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return DefaultNormalizationPrefs, nil
+ }
+ if err != nil {
+ return DefaultNormalizationPrefs, err
+ }
+ return NormalizationPrefs{Mode: row.Mode, TargetLUFS: row.TargetLufs, Boost: row.Boost}, nil
+}
+
+// SaveNormalizationPrefs validates and stores the user's preference,
+// returning what was stored.
+func SaveNormalizationPrefs(ctx context.Context, q *dbq.Queries, userID pgtype.UUID, p NormalizationPrefs) (NormalizationPrefs, error) {
+ if err := p.Validate(); err != nil {
+ return p, err
+ }
+ row, err := q.UpsertNormalizationPrefs(ctx, dbq.UpsertNormalizationPrefsParams{
+ UserID: userID, Mode: p.Mode, TargetLufs: p.TargetLUFS, Boost: p.Boost,
+ })
+ if err != nil {
+ return p, err
+ }
+ return NormalizationPrefs{Mode: row.Mode, TargetLUFS: row.TargetLufs, Boost: row.Boost}, nil
+}
diff --git a/internal/library/normalization_prefs_test.go b/internal/library/normalization_prefs_test.go
new file mode 100644
index 00000000..6dd54616
--- /dev/null
+++ b/internal/library/normalization_prefs_test.go
@@ -0,0 +1,69 @@
+package library
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
+ "git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
+)
+
+func TestNormalizationPrefs_Validate(t *testing.T) {
+ if err := DefaultNormalizationPrefs.Validate(); err != nil {
+ t.Fatalf("the defaults are invalid: %v", err)
+ }
+ for _, p := range []NormalizationPrefs{
+ {Mode: "loud", TargetLUFS: -18, Boost: "headroom"},
+ {Mode: "auto", TargetLUFS: -23, Boost: "headroom"},
+ {Mode: "auto", TargetLUFS: -18, Boost: "max"},
+ // A field left out of a PUT body decodes as its zero value.
+ {Mode: "auto", Boost: "headroom"},
+ } {
+ if err := p.Validate(); !errors.Is(err, ErrNormalizationPrefsInvalid) {
+ t.Errorf("%+v: err = %v, want ErrNormalizationPrefsInvalid", p, err)
+ }
+ }
+}
+
+// A user with no row gets the Go defaults, and a stored row reads back the
+// migration's defaults for any column the insert left out; the two must
+// agree, or a user would hear a different level after their first save.
+func TestNormalizationPrefs_DefaultsAndRoundTrip_Integration(t *testing.T) {
+ pool := newPool(t)
+ ctx := context.Background()
+ q := dbq.New(pool)
+ user, err := q.CreateUser(ctx, dbq.CreateUserParams{
+ Username: dbtest.TestUserPrefix + "norm-prefs", PasswordHash: "x", ApiTokenHash: "norm-prefs-token",
+ })
+ if err != nil {
+ t.Fatalf("user: %v", err)
+ }
+
+ got, err := LoadNormalizationPrefs(ctx, q, user.ID)
+ if err != nil || got != DefaultNormalizationPrefs {
+ t.Fatalf("unset prefs = %+v, %v; want the defaults", got, err)
+ }
+
+ if _, err := pool.Exec(ctx, "INSERT INTO user_normalization_prefs (user_id) VALUES ($1)", user.ID); err != nil {
+ t.Fatalf("insert defaults row: %v", err)
+ }
+ got, err = LoadNormalizationPrefs(ctx, q, user.ID)
+ if err != nil || got != DefaultNormalizationPrefs {
+ t.Errorf("migration defaults = %+v, Go defaults = %+v (err %v)", got, DefaultNormalizationPrefs, err)
+ }
+
+ want := NormalizationPrefs{Mode: "track", TargetLUFS: -14, Boost: "limiter"}
+ if saved, err := SaveNormalizationPrefs(ctx, q, user.ID, want); err != nil || saved != want {
+ t.Fatalf("save = %+v, %v; want %+v", saved, err, want)
+ }
+ if got, _ := LoadNormalizationPrefs(ctx, q, user.ID); got != want {
+ t.Errorf("after save = %+v, want %+v", got, want)
+ }
+ if _, err := SaveNormalizationPrefs(ctx, q, user.ID, NormalizationPrefs{Mode: "x"}); !errors.Is(err, ErrNormalizationPrefsInvalid) {
+ t.Errorf("invalid save: err = %v", err)
+ }
+ if got, _ := LoadNormalizationPrefs(ctx, q, user.ID); got != want {
+ t.Errorf("an invalid save changed the stored prefs to %+v", got)
+ }
+}
diff --git a/web/src/lib/api/normalization.ts b/web/src/lib/api/normalization.ts
new file mode 100644
index 00000000..c9ddad15
--- /dev/null
+++ b/web/src/lib/api/normalization.ts
@@ -0,0 +1,42 @@
+import { api } from './client';
+
+// Loudness normalization preference (M464 #4998). One per user, stored on
+// the server so the web player, the Android app and casts agree.
+export type NormalizationMode = 'off' | 'auto' | 'track' | 'album';
+export type NormalizationTarget = -18 | -16 | -14;
+export type NormalizationBoost = 'headroom' | 'limiter';
+
+export type NormalizationPrefs = {
+ mode: NormalizationMode;
+ target_lufs: NormalizationTarget;
+ boost: NormalizationBoost;
+};
+
+// Must match library.DefaultNormalizationPrefs on the server.
+export const DEFAULT_NORMALIZATION: NormalizationPrefs = {
+ mode: 'auto',
+ target_lufs: -18,
+ boost: 'headroom'
+};
+
+export const NORMALIZATION_MODES: NormalizationMode[] = ['off', 'auto', 'track', 'album'];
+export const NORMALIZATION_TARGETS: NormalizationTarget[] = [-18, -16, -14];
+
+export function isNormalizationPrefs(v: unknown): v is NormalizationPrefs {
+ if (typeof v !== 'object' || v === null) return false;
+ const p = v as Record;
+ return (
+ NORMALIZATION_MODES.includes(p.mode as NormalizationMode) &&
+ NORMALIZATION_TARGETS.includes(p.target_lufs as NormalizationTarget) &&
+ (p.boost === 'headroom' || p.boost === 'limiter')
+ );
+}
+
+export function getNormalization(): Promise {
+ return api.get('/api/me/normalization');
+}
+
+// The whole preference is sent every time; the server keeps the last write.
+export function putNormalization(p: NormalizationPrefs): Promise {
+ return api.put('/api/me/normalization', p);
+}
diff --git a/web/src/lib/components/NormalizationSetting.svelte b/web/src/lib/components/NormalizationSetting.svelte
new file mode 100644
index 00000000..d147d29c
--- /dev/null
+++ b/web/src/lib/components/NormalizationSetting.svelte
@@ -0,0 +1,104 @@
+
+
+
diff --git a/web/src/lib/components/NormalizationSetting.test.ts b/web/src/lib/components/NormalizationSetting.test.ts
new file mode 100644
index 00000000..ef5317c9
--- /dev/null
+++ b/web/src/lib/components/NormalizationSetting.test.ts
@@ -0,0 +1,65 @@
+import { afterEach, describe, expect, test, vi } from 'vitest';
+import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
+
+vi.mock('$lib/api/normalization', async (importOriginal) => ({
+ ...(await importOriginal()),
+ getNormalization: vi.fn(),
+ putNormalization: vi.fn()
+}));
+vi.mock('$lib/stores/toast.svelte', () => ({ pushToast: vi.fn() }));
+
+import NormalizationSetting from './NormalizationSetting.svelte';
+import { getNormalization, putNormalization } from '$lib/api/normalization';
+import { normalization } from '$lib/stores/normalization.svelte';
+import { pushToast } from '$lib/stores/toast.svelte';
+
+afterEach(() => {
+ vi.clearAllMocks();
+ localStorage.clear();
+});
+
+describe('NormalizationSetting', () => {
+ test('shows the server value and saves the whole preference on a change', async () => {
+ vi.mocked(getNormalization).mockResolvedValue({ mode: 'track', target_lufs: -16, boost: 'headroom' });
+ vi.mocked(putNormalization).mockImplementation(async (p) => p);
+ render(NormalizationSetting);
+
+ await waitFor(() => expect(screen.getByRole('radio', { name: 'Track' })).toBeChecked());
+ expect(screen.getByRole('radio', { name: '-16 LUFS' })).toBeChecked();
+
+ await fireEvent.click(screen.getByRole('radio', { name: 'Album' }));
+ await waitFor(() =>
+ expect(putNormalization).toHaveBeenCalledWith({ mode: 'album', target_lufs: -16, boost: 'headroom' })
+ );
+ await fireEvent.click(screen.getByRole('checkbox', { name: /boost quiet tracks/i }));
+ await waitFor(() =>
+ expect(putNormalization).toHaveBeenLastCalledWith({ mode: 'album', target_lufs: -16, boost: 'limiter' })
+ );
+ // The last value seen is cached for the player's first track of a session.
+ expect(JSON.parse(localStorage.getItem('minstrel:normalization') ?? 'null')).toEqual({
+ mode: 'album',
+ target_lufs: -16,
+ boost: 'limiter'
+ });
+ });
+
+ test('a failed save puts the previous choice back and says so', async () => {
+ vi.mocked(getNormalization).mockResolvedValue({ mode: 'auto', target_lufs: -18, boost: 'headroom' });
+ vi.mocked(putNormalization).mockRejectedValue(new Error('offline'));
+ render(NormalizationSetting);
+ await waitFor(() => expect(screen.getByRole('radio', { name: 'Auto' })).toBeChecked());
+
+ await fireEvent.click(screen.getByRole('radio', { name: 'Off' }));
+ await waitFor(() => expect(pushToast).toHaveBeenCalledWith('Could not save volume leveling.', 'error'));
+ expect(normalization.value.mode).toBe('auto');
+ expect(screen.getByRole('radio', { name: 'Auto' })).toBeChecked();
+ });
+
+ test('off hides the target and boost controls', async () => {
+ vi.mocked(getNormalization).mockResolvedValue({ mode: 'off', target_lufs: -18, boost: 'headroom' });
+ render(NormalizationSetting);
+ await waitFor(() => expect(screen.getByRole('radio', { name: 'Off' })).toBeChecked());
+ expect(screen.queryByRole('radio', { name: '-18 LUFS' })).toBeNull();
+ expect(screen.queryByRole('checkbox')).toBeNull();
+ });
+});
diff --git a/web/src/lib/stores/normalization.svelte.ts b/web/src/lib/stores/normalization.svelte.ts
new file mode 100644
index 00000000..ff0b940c
--- /dev/null
+++ b/web/src/lib/stores/normalization.svelte.ts
@@ -0,0 +1,66 @@
+import * as storage from '$lib/util/safeLocalStorage';
+import {
+ DEFAULT_NORMALIZATION,
+ getNormalization,
+ isNormalizationPrefs,
+ putNormalization,
+ type NormalizationPrefs
+} from '$lib/api/normalization';
+
+// The signed-in user's normalization preference. The server holds it; this
+// keeps the last value seen in localStorage too, so the player levels the
+// first track of a session before the fetch returns.
+const STORAGE_KEY = 'minstrel:normalization';
+
+function readCached(): NormalizationPrefs {
+ try {
+ const v: unknown = JSON.parse(storage.read(STORAGE_KEY) ?? 'null');
+ return isNormalizationPrefs(v) ? v : DEFAULT_NORMALIZATION;
+ } catch {
+ return DEFAULT_NORMALIZATION;
+ }
+}
+
+let _prefs = $state(readCached());
+// Bumped by every change made here. A load or a save that resolves after a
+// newer change must not overwrite it.
+let _generation = 0;
+
+export const normalization = {
+ get value(): NormalizationPrefs {
+ return _prefs;
+ }
+};
+
+function commit(p: NormalizationPrefs): void {
+ _prefs = p;
+ storage.write(STORAGE_KEY, JSON.stringify(p));
+}
+
+/** Refreshes from the server; on failure the cached value stands. */
+export async function loadNormalization(): Promise {
+ const gen = _generation;
+ try {
+ const p = await getNormalization();
+ if (gen === _generation) commit(p);
+ } catch {
+ // Offline or signed out: keep what we had.
+ }
+}
+
+/**
+ * Applies [next] at once, then saves it. If the save fails the previous
+ * value is restored and the error rethrown for the caller to report.
+ */
+export async function setNormalization(next: NormalizationPrefs): Promise {
+ const prev = _prefs;
+ const gen = ++_generation;
+ commit(next);
+ try {
+ const saved = await putNormalization(next);
+ if (gen === _generation) commit(saved);
+ } catch (err) {
+ if (gen === _generation) commit(prev);
+ throw err;
+ }
+}
diff --git a/web/src/routes/settings/+page.svelte b/web/src/routes/settings/+page.svelte
index d4abe4e3..2f58dcfd 100644
--- a/web/src/routes/settings/+page.svelte
+++ b/web/src/routes/settings/+page.svelte
@@ -30,6 +30,7 @@
import MobileAppDownload from '$lib/components/MobileAppDownload.svelte';
import ServerVersion from '$lib/components/ServerVersion.svelte';
import ActiveSessions from '$lib/components/ActiveSessions.svelte';
+ import NormalizationSetting from '$lib/components/NormalizationSetting.svelte';
const queryClient = useQueryClient();
@@ -341,6 +342,8 @@
0 = off · most albums sound best at 0.
+
+
diff --git a/web/src/routes/settings/Appearance.test.ts b/web/src/routes/settings/Appearance.test.ts
index 67407e35..d982fb2f 100644
--- a/web/src/routes/settings/Appearance.test.ts
+++ b/web/src/routes/settings/Appearance.test.ts
@@ -25,6 +25,16 @@ vi.mock('$lib/api/listenbrainz', () => {
};
});
+// The Volume leveling setting loads on mount; keep it off the network.
+vi.mock('$lib/api/normalization', async (importOriginal) => {
+ const mod = await importOriginal();
+ return {
+ ...mod,
+ getNormalization: vi.fn().mockResolvedValue(mod.DEFAULT_NORMALIZATION),
+ putNormalization: vi.fn()
+ };
+});
+
vi.mock('$lib/api/metrics', () => ({
createRecommendationMetricsQuery: () => ({
subscribe: (run: (v: unknown) => void) => {
diff --git a/web/src/routes/settings/settings.test.ts b/web/src/routes/settings/settings.test.ts
index 5ffd2b16..746c78bd 100644
--- a/web/src/routes/settings/settings.test.ts
+++ b/web/src/routes/settings/settings.test.ts
@@ -11,6 +11,16 @@ vi.mock('$lib/api/listenbrainz', () => ({
setListenBrainzEnabled: vi.fn()
}));
+// The Volume leveling setting loads on mount; keep it off the network.
+vi.mock('$lib/api/normalization', async (importOriginal) => {
+ const mod = await importOriginal();
+ return {
+ ...mod,
+ getNormalization: vi.fn().mockResolvedValue(mod.DEFAULT_NORMALIZATION),
+ putNormalization: vi.fn()
+ };
+});
+
vi.mock('$lib/api/me', () => ({
updateProfile: vi.fn(),
changePassword: vi.fn(),
From d5dfcf5b7ce4db5bc6fd8f565d3fd3089f773d9b Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Tue, 6 Oct 2026 14:34:28 -0400
Subject: [PATCH 02/12] fix: boost control is a switch; MutationQueue keeps one
enqueue per kind (M464 #4998)
The ListenBrainz settings test finds the page's one checkbox, and the
boost control is a toggle anyway. detekt counts MutationQueue's enqueue
functions; suppressed as the replayer's dispatchers already are.
Co-Authored-By: Claude Opus 5.5
---
.../com/fabledsword/minstrel/cache/mutations/MutationQueue.kt | 1 +
web/src/lib/components/NormalizationSetting.svelte | 2 ++
web/src/lib/components/NormalizationSetting.test.ts | 4 ++--
3 files changed, 5 insertions(+), 2 deletions(-)
diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/mutations/MutationQueue.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/mutations/MutationQueue.kt
index be70247a..32ea636c 100644
--- a/android/app/src/main/java/com/fabledsword/minstrel/cache/mutations/MutationQueue.kt
+++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/mutations/MutationQueue.kt
@@ -91,6 +91,7 @@ data class RequestCreatePayload(
* This matches `feedback_offline_first_for_server_writes` — writes
* never go fire-and-forget.
*/
+@Suppress("TooManyFunctions") // one enqueue per mutation kind, like the replayer's dispatchers
@Singleton
class MutationQueue @Inject constructor(
private val dao: CachedMutationDao,
diff --git a/web/src/lib/components/NormalizationSetting.svelte b/web/src/lib/components/NormalizationSetting.svelte
index d147d29c..e6bb8ab5 100644
--- a/web/src/lib/components/NormalizationSetting.svelte
+++ b/web/src/lib/components/NormalizationSetting.svelte
@@ -94,6 +94,8 @@
change({ boost: (e.currentTarget as HTMLInputElement).checked ? 'limiter' : 'headroom' })}
diff --git a/web/src/lib/components/NormalizationSetting.test.ts b/web/src/lib/components/NormalizationSetting.test.ts
index ef5317c9..eb671d6b 100644
--- a/web/src/lib/components/NormalizationSetting.test.ts
+++ b/web/src/lib/components/NormalizationSetting.test.ts
@@ -31,7 +31,7 @@ describe('NormalizationSetting', () => {
await waitFor(() =>
expect(putNormalization).toHaveBeenCalledWith({ mode: 'album', target_lufs: -16, boost: 'headroom' })
);
- await fireEvent.click(screen.getByRole('checkbox', { name: /boost quiet tracks/i }));
+ await fireEvent.click(screen.getByRole('switch', { name: /boost quiet tracks/i }));
await waitFor(() =>
expect(putNormalization).toHaveBeenLastCalledWith({ mode: 'album', target_lufs: -16, boost: 'limiter' })
);
@@ -60,6 +60,6 @@ describe('NormalizationSetting', () => {
render(NormalizationSetting);
await waitFor(() => expect(screen.getByRole('radio', { name: 'Off' })).toBeChecked());
expect(screen.queryByRole('radio', { name: '-18 LUFS' })).toBeNull();
- expect(screen.queryByRole('checkbox')).toBeNull();
+ expect(screen.queryByRole('switch')).toBeNull();
});
});
From 2a7eb3dd19f40b3162e8674784f065ff4e0ef573 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Tue, 6 Oct 2026 17:52:01 -0400
Subject: [PATCH 03/12] ci(integration): give the suite a 20m package timeout
internal/api takes ~6.5 min under -race on an idle runner; with a second
run on the same runner it crossed go test's default 10m (run 8368: FAIL
at 600.016s with the running test 2s old, so load, not a hang).
Co-Authored-By: Claude Opus 5.5
---
.gitea/workflows/release.yml | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml
index 6a0bf115..1378cde2 100644
--- a/.gitea/workflows/release.yml
+++ b/.gitea/workflows/release.yml
@@ -272,8 +272,12 @@ jobs:
# every integration package TRUNCATEs the one shared test DB;
# concurrent package binaries → TRUNCATE deadlocks. Serialize
# package execution (the documented local invocation too).
+ # -timeout 20m: internal/api alone takes ~6.5 min under -race on
+ # an idle runner, and a dev and a main run sharing the runner
+ # pushed it past go test's default 10m (run 8368, 600.016s, the
+ # running test 2s old — nothing hung).
MINSTREL_DATABASE_URL="$MINSTREL_TEST_DATABASE_URL" go run ./cmd/minstrel migrate
- go test -p 1 -race ./...
+ go test -p 1 -race -timeout 20m ./...
web:
runs-on: go-ci
From 38290bf8f921a82361bed610fedaa1a76f058b88 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Tue, 6 Oct 2026 18:21:41 -0400
Subject: [PATCH 04/12] feat(web): level playback by the user's normalization
preference (M464 #4999)
Cuts go through element.volume. Boosts route the element through a Web
Audio GainNode and a DynamicsCompressor (a -1 dBFS limiter in limiter
mode, a pass-through otherwise), built only when a track wants a boost
and only once an AudioContext is confirmed running; iOS never gets the
graph. Auto mode takes album gain when a queue neighbour is from the
same album in track order. Gains are fetched for the next 50 tracks as
the queue moves, with a 10s deadline. The prefetch element is untouched.
Co-Authored-By: Claude Opus 5.5
---
web/src/lib/api/normalization.ts | 22 ++-
web/src/lib/player/gain.test.ts | 86 +++++++++++
web/src/lib/player/gain.ts | 66 +++++++++
web/src/lib/player/gainStage.svelte.test.ts | 62 ++++++++
web/src/lib/player/gainStage.svelte.ts | 152 ++++++++++++++++++++
web/src/routes/+layout.svelte | 42 +++++-
web/src/routes/+layout.ts | 7 +-
7 files changed, 434 insertions(+), 3 deletions(-)
create mode 100644 web/src/lib/player/gain.test.ts
create mode 100644 web/src/lib/player/gain.ts
create mode 100644 web/src/lib/player/gainStage.svelte.test.ts
create mode 100644 web/src/lib/player/gainStage.svelte.ts
diff --git a/web/src/lib/api/normalization.ts b/web/src/lib/api/normalization.ts
index c9ddad15..9c7db924 100644
--- a/web/src/lib/api/normalization.ts
+++ b/web/src/lib/api/normalization.ts
@@ -1,4 +1,4 @@
-import { api } from './client';
+import { api, apiFetch } from './client';
// Loudness normalization preference (M464 #4998). One per user, stored on
// the server so the web player, the Android app and casts agree.
@@ -40,3 +40,23 @@ export function getNormalization(): Promise {
export function putNormalization(p: NormalizationPrefs): Promise {
return api.put('/api/me/normalization', p);
}
+
+// Per-track gains (#4997): dB to the -18 LUFS ReplayGain reference and linear
+// peaks. A field is absent until the server has measured it.
+export type ReplayGain = {
+ track_gain?: number;
+ track_peak?: number;
+ album_gain?: number;
+ album_peak?: number;
+};
+
+/**
+ * Gains for up to 200 tracks; an id missing from items has none yet. Given
+ * a deadline: the player waits on it, and a request that never answers
+ * would leave those tracks unleveled for the rest of the session.
+ */
+export function getReplayGain(ids: string[]): Promise<{ items: Record }> {
+ return apiFetch(`/api/tracks/replay-gain?ids=${ids.map(encodeURIComponent).join(',')}`, {
+ signal: AbortSignal.timeout(10_000)
+ }) as Promise<{ items: Record }>;
+}
diff --git a/web/src/lib/player/gain.test.ts b/web/src/lib/player/gain.test.ts
new file mode 100644
index 00000000..0281907a
--- /dev/null
+++ b/web/src/lib/player/gain.test.ts
@@ -0,0 +1,86 @@
+import { describe, expect, test } from 'vitest';
+import { dbToLinear, gainDb, playingAsAlbum, MAX_BOOST_DB } from './gain';
+import type { NormalizationPrefs } from '$lib/api/normalization';
+import type { TrackRef } from '$lib/api/types';
+import { makeTrack } from '$test-utils/fixtures/track';
+
+const prefs = (over: Partial = {}): NormalizationPrefs => ({
+ mode: 'auto',
+ target_lufs: -18,
+ boost: 'headroom',
+ ...over
+});
+
+const track = (id: string, album: string, n?: number, disc?: number): TrackRef =>
+ makeTrack({ id, album_id: album, track_number: n, disc_number: disc });
+
+describe('gainDb', () => {
+ const g = { track_gain: -6, track_peak: 1, album_gain: -4, album_peak: 1 };
+
+ test('off and unmeasured tracks play as mastered', () => {
+ expect(gainDb(prefs({ mode: 'off' }), g, false)).toBe(0);
+ expect(gainDb(prefs(), undefined, false)).toBe(0);
+ expect(gainDb(prefs(), {}, false)).toBe(0);
+ });
+
+ test('mode picks the gain; auto follows album play', () => {
+ expect(gainDb(prefs({ mode: 'track' }), g, true)).toBe(-6);
+ expect(gainDb(prefs({ mode: 'album' }), g, false)).toBe(-4);
+ expect(gainDb(prefs({ mode: 'auto' }), g, true)).toBe(-4);
+ expect(gainDb(prefs({ mode: 'auto' }), g, false)).toBe(-6);
+ });
+
+ test('album gain falls back to track gain until the album is measured', () => {
+ expect(gainDb(prefs({ mode: 'album' }), { track_gain: -6, track_peak: 1 }, true)).toBe(-6);
+ });
+
+ test('a louder target raises every gain by the difference', () => {
+ expect(gainDb(prefs({ mode: 'track', target_lufs: -14 }), g, false)).toBe(-2);
+ });
+
+ test('headroom stops a boost 1 dB under the true peak; the limiter lets it through', () => {
+ // +8 dB wanted; the peak at 0.5 (about -6 dBTP) leaves 5 dB of room.
+ const quiet = { track_gain: 8, track_peak: 0.5 };
+ expect(gainDb(prefs({ mode: 'track' }), quiet, false)).toBeCloseTo(-1 - 20 * Math.log10(0.5), 5);
+ expect(gainDb(prefs({ mode: 'track', boost: 'limiter' }), quiet, false)).toBe(8);
+ });
+
+ test('no boost passes the cap, limiter or not', () => {
+ const silent = { track_gain: 30, track_peak: 0.001 };
+ expect(gainDb(prefs({ mode: 'track', boost: 'limiter' }), silent, false)).toBe(MAX_BOOST_DB);
+ });
+
+ test('cuts are never limited by the peak', () => {
+ expect(gainDb(prefs({ mode: 'track' }), { track_gain: -9, track_peak: 1.4 }, false)).toBe(-9);
+ });
+});
+
+describe('playingAsAlbum', () => {
+ test('in-order neighbours from the same album', () => {
+ const q = [track('1', 'x', 1), track('2', 'x', 2), track('3', 'x', 3)];
+ expect(playingAsAlbum(q, 0)).toBe(true);
+ expect(playingAsAlbum(q, 1)).toBe(true);
+ expect(playingAsAlbum(q, 2)).toBe(true);
+ });
+
+ test('a shuffled album, a mix and a lone track are not album play', () => {
+ expect(playingAsAlbum([track('3', 'x', 3), track('1', 'x', 1)], 0)).toBe(false);
+ expect(playingAsAlbum([track('1', 'x', 1), track('2', 'y', 2)], 0)).toBe(false);
+ expect(playingAsAlbum([track('1', 'x', 1)], 0)).toBe(false);
+ });
+
+ test('disc order counts: disc 2 track 1 follows disc 1 track 12', () => {
+ const q = [track('a', 'x', 12, 1), track('b', 'x', 1, 2)];
+ expect(playingAsAlbum(q, 1)).toBe(true);
+ });
+
+ test('neighbours without track numbers are not evidence of order', () => {
+ expect(playingAsAlbum([track('1', 'x'), track('2', 'x')], 0)).toBe(false);
+ });
+});
+
+test('dbToLinear', () => {
+ expect(dbToLinear(0)).toBe(1);
+ expect(dbToLinear(-20)).toBeCloseTo(0.1, 6);
+ expect(dbToLinear(6)).toBeCloseTo(1.995, 3);
+});
diff --git a/web/src/lib/player/gain.ts b/web/src/lib/player/gain.ts
new file mode 100644
index 00000000..ecb0324f
--- /dev/null
+++ b/web/src/lib/player/gain.ts
@@ -0,0 +1,66 @@
+// Loudness normalization math for the web player (M464 #4999). Pure, so the
+// rules are tested without an