From af36b2f24a19e8c78afda04a85950408711964cd Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 6 Oct 2026 14:29:40 -0400 Subject: [PATCH] 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 @@ + + +
+
+ Volume leveling +
+ {#each NORMALIZATION_MODES as m (m)} + + {/each} +
+

{modeHints[normalization.value.mode]}

+
+ + {#if normalization.value.mode !== 'off'} +
+ Target loudness +
+ {#each NORMALIZATION_TARGETS as t (t)} + + {/each} +
+

Higher is louder; −18 leaves the most headroom.

+
+ + + {/if} +
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(),