diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 6a0bf115..3988729e 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 @@ -352,22 +356,11 @@ jobs: - name: Unit tests run: ./gradlew testDebugUnitTest - - name: Assemble debug - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - run: ./gradlew assembleDebug - - - name: Upload debug APK - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - # Stock action: it works on this forge since the runner moved to - # gitea/runner 3.x, which edits upload-artifact's client-side GHES refusal - # out of the action bundle (Scribe snippet #2271). Never @v3 — it reports - # success while Gitea serves artifacts back only through the v4 API, and - # it is what left 72 unreachable artifacts on this repo (Scribe 2270). - uses: actions/upload-artifact@v7 - with: - name: minstrel-android-debug-${{ github.sha }} - path: android/app/build/outputs/apk/debug/app-debug.apk - if-no-files-found: error + # No debug APK is built or uploaded here. Main used to upload a + # debug-signed app-debug.apk: a build signed by a key regenerated in + # every container, which no install can update (family idea #5103, + # practice 2). Phones get builds from android-release, signed with + # the one release key, on dev and on tags. # Known vulnerabilities in the Go code and the standard library it is built # with. Runs in the SAME image the Dockerfile's builder stage uses, so the @@ -530,6 +523,42 @@ jobs: -PMINSTREL_VERSION_NAME=${{ steps.ver.outputs.name }} \ -PMINSTREL_VERSION_CODE=${{ steps.ver.outputs.code }} + # The APK every phone updates from must carry THE release key: Android + # updates an app in place only when the signer matches, so an APK + # signed by any other key (debug, a regenerated keystore, a swapped + # secret) reaches no installed phone. The certificate's digest is + # pinned below; it is public, not a secret. Gradle signs with the + # release key or leaves the APK unsigned, and an unsigned build fails + # here too, as there is no app-release.apk to verify (family idea + # #5103, practice 3). apksigner, not keytool: keytool prints nothing + # for a v2-only APK. + # + # Rotating the key on purpose means every install must be removed and + # reinstalled; change the digest here in the same commit. + - name: The APK carries the release key + shell: bash + env: + # CN=Minstrel, O=FabledSword. Read from run 8446 (#5116). + RELEASE_CERT_SHA256: 43d183307bc46b821789d90444a960b137f78f2166ff431efb2406d0fceaf612 + run: | + set -euo pipefail + sdk="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}" + signer="$(ls "$sdk"/build-tools/*/apksigner 2>/dev/null | sort -V | tail -1 || true)" + test -n "$signer" || { echo "::error::no apksigner under '$sdk/build-tools'"; exit 1; } + certs="$("$signer" verify --print-certs app/build/outputs/apk/release/app-release.apk)" + printf '%s\n' "$certs" | grep -E '^Signer #[0-9]+ certificate (DN|SHA-256 digest)' + # One signer, and it is ours. A second signer would be a lineage or + # a mistake; either way not something to ship unexamined. + digests="$(printf '%s\n' "$certs" | sed -n 's/^Signer #[0-9]* certificate SHA-256 digest: //p')" + if [ "$digests" != "$RELEASE_CERT_SHA256" ]; then + if printf '%s' "$certs" | grep -q 'CN=Android Debug'; then + echo "::error::the release APK is signed with a debug key" + else + echo "::error::the release APK is not signed by the release key: got '${digests//$'\n'/ }', want ${RELEASE_CERT_SHA256}" + fi + exit 1 + fi + - name: Upload APK as workflow artifact # Stock action (snippet #2271) — never @v3, which uploads something Gitea # will never serve back. This is the producing half of a pair: diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index dc6d264b..e185e97e 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -72,9 +72,13 @@ android { getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro", ) + // Signed with the release key or not at all. Falling back to the + // debug key made a missing secret into a published APK that no + // install could ever update (family idea #5103, practice 2). An + // unsigned build installs nowhere, so the gap shows at once. signingConfig = if (System.getenv("ANDROID_KEYSTORE_PATH").isNullOrEmpty()) { - signingConfigs.getByName("debug") + null } else { signingConfigs.getByName("release") } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/BaseUrlInterceptor.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/BaseUrlInterceptor.kt index 8bc5fee1..42d38e51 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/api/BaseUrlInterceptor.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/BaseUrlInterceptor.kt @@ -50,7 +50,14 @@ class BaseUrlInterceptor @Inject constructor( .port(baseUrl.port) .build() } ?: original.url - return chain.proceed(original.newBuilder().url(rewritten).build()) + return chain.proceed( + original.newBuilder() + .url(rewritten) + // Lets CleartextGuardInterceptor tell server requests from + // external fetches once the placeholder host is gone. + .tag(MinstrelServerRequest::class.java, MinstrelServerRequest) + .build(), + ) } companion object { diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/CleartextGuard.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/CleartextGuard.kt new file mode 100644 index 00000000..e60857c2 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/CleartextGuard.kt @@ -0,0 +1,84 @@ +package com.fabledsword.minstrel.api + +import okhttp3.Interceptor +import okhttp3.Response +import java.io.IOException +import java.net.Inet4Address +import java.net.Inet6Address +import java.net.InetAddress + +/** + * Marks a request as bound for the Minstrel server, set by + * [BaseUrlInterceptor] when it retargets the placeholder host. Those are the + * requests that carry the session cookie and the password. + */ +object MinstrelServerRequest + +/** + * Plain `http://` to the Minstrel server is allowed only when the connection + * actually lands on a private address (family security baseline #5105, + * practice 13). + * + * Cleartext stays permitted app-wide for LAN servers and UPnP + * (network_security_config.xml, #2439), but a password or session cookie sent + * over plain HTTP to a public address can be read by anyone on the path. + * + * Checked per connection, on the address the socket really reached, not on + * the URL when it was typed: a name that resolved to the home network when it + * was entered resolves to a public address once the phone leaves home, and + * that is exactly when the password would go out in the clear. A network + * interceptor runs after the connection is made and before any request byte + * is written, so nothing is sent. + */ +class CleartextGuardInterceptor( + // The policy is a parameter so a test can refuse loopback, the only + // address a test server can listen on. + private val allows: (InetAddress) -> Boolean = CleartextPolicy::allows, +) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + if (request.isHttps || request.tag(MinstrelServerRequest::class.java) == null) { + return chain.proceed(request) + } + val address = chain.connection()?.route()?.socketAddress?.address + if (address != null && !allows(address)) { + throw CleartextToPublicHostException(request.url.host) + } + return chain.proceed(request) + } +} + +/** The server was reached over plain HTTP at a public address, and refused. */ +class CleartextToPublicHostException(host: String) : + IOException("refusing plain http:// to $host: it is a public address") + +/** Which addresses plain HTTP may reach: the home network, never the internet. */ +object CleartextPolicy { + private const val CGNAT_FIRST_OCTET = 100 + private const val CGNAT_SECOND_MASK = 0xC0 + private const val CGNAT_SECOND_PREFIX = 64 + private const val ULA_MASK = 0xFE + private const val ULA_PREFIX = 0xFC + private const val BYTE = 0xFF + + fun allows(address: InetAddress): Boolean = + address.isLoopbackAddress || + address.isSiteLocalAddress || // 10/8, 172.16/12, 192.168/16 + address.isLinkLocalAddress || // 169.254/16, fe80::/10 + address.isAnyLocalAddress || + isCarrierGradeNat(address) || + isUniqueLocal(address) + + // 100.64/10. Tailscale and other overlay VPNs hand these out; the overlay + // encrypts the traffic itself. + private fun isCarrierGradeNat(address: InetAddress): Boolean { + if (address !is Inet4Address) return false + val b = address.address + return (b[0].toInt() and BYTE) == CGNAT_FIRST_OCTET && + (b[1].toInt() and CGNAT_SECOND_MASK) == CGNAT_SECOND_PREFIX + } + + // fc00::/7, IPv6's private range. + private fun isUniqueLocal(address: InetAddress): Boolean = + address is Inet6Address && (address.address[0].toInt() and ULA_MASK) == ULA_PREFIX +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/ErrorCopy.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/ErrorCopy.kt index 9a7314ad..360ba82b 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/api/ErrorCopy.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/ErrorCopy.kt @@ -38,6 +38,7 @@ object ErrorCopy { */ fun fromThrowable(t: Throwable): String = when (t) { is HttpException -> fromHttp(t) + is CleartextToPublicHostException -> messageFor("cleartext_public") is IOException -> messageFor("connection_refused") else -> TABLE.getValue("unknown") } @@ -101,6 +102,9 @@ object ErrorCopy { "mbid_required" to "An MBID is required for this lookup.", "system_playlist_readonly" to "System playlists can't be edited directly.", "connection_refused" to "Couldn't reach the server. Check the URL and try again.", + "cleartext_public" to + "This server is on the internet, so its URL must start with https://. " + + "Plain http:// only works on your home network.", "lidarr_unreachable" to "Lidarr is unreachable right now. Try again, or check Admin → Integrations.", "lidarr_disabled" to "Lidarr integration is not enabled.", diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/NetworkModule.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/NetworkModule.kt index 91233f84..475b2842 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/api/NetworkModule.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/NetworkModule.kt @@ -70,6 +70,9 @@ object NetworkModule { .addInterceptor(auth) .addInterceptor(baseUrl) .addInterceptor(logging) + // A network interceptor, so it sees the address the connection + // really reached and runs before any request byte is written. + .addNetworkInterceptor(CleartextGuardInterceptor()) .connectTimeout(CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS) .readTimeout(READ_TIMEOUT_SECONDS, TimeUnit.SECONDS) .build() diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/CastApi.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/CastApi.kt index e291f4a5..0e2fb3d6 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/CastApi.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/CastApi.kt @@ -29,11 +29,20 @@ interface CastApi { * Request body. [expSeconds] is clamped server-side to [60, 86400]; * the 21_600 default (6h) is long enough to play through any typical * track without re-minting mid-playback. + * + * [level] asks for the leveled stream (M464 #5001): the track rendered at + * the user's loudness gain, which the server works out from their setting. + * [asAlbum] says the track plays among its album in order, which picks + * album gain in auto mode. [prerender] says the speaker will fetch it + * soon, so the server renders it ahead. */ @Serializable data class StreamTokenRequest( val trackId: String, val expSeconds: Int = 21_600, + val level: Boolean = false, + val asAlbum: Boolean = false, + val prerender: Boolean = false, ) /** @@ -53,4 +62,6 @@ data class StreamTokenResponse( val url: String, val mime: String = "audio/mpeg", val title: String = "", + /** [url] is the leveled stream; false when leveling is off or changes nothing. */ + val leveled: Boolean = false, ) 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/api/endpoints/ReplayGainApi.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/ReplayGainApi.kt new file mode 100644 index 00000000..d6356c87 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/ReplayGainApi.kt @@ -0,0 +1,15 @@ +package com.fabledsword.minstrel.api.endpoints + +import com.fabledsword.minstrel.models.wire.ReplayGainResponseWire +import retrofit2.http.GET +import retrofit2.http.Query + +/** The player's loudness lookup (#4997), kept apart from the browse surface in [LibraryApi]. */ +interface ReplayGainApi { + /** + * ReplayGain values for up to 200 comma-separated track ids. An id + * missing from `items` has not been measured yet. + */ + @GET("api/tracks/replay-gain") + suspend fun getReplayGain(@Query("ids") ids: String): ReplayGainResponseWire +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/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..49dc21db 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/AppDatabase.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/AppDatabase.kt @@ -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,22 @@ import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity AuthSessionEntity::class, DiagnosticEventEntity::class, ], + // v10: + cached_tracks.trackGain/trackPeak and cached_albums.albumGain/ + // albumPeak, the ReplayGain values the player levels by (M464 #5000). + // MIGRATION_9_10 also rewinds the sync cursor, so the next sync re-sends + // every row and an existing cache gains its values. + // v9: + auth_session.normalizationJson, the loudness-normalization + // preference (M464 #4998). The first schema step with an explicit + // Migration (MIGRATION_8_9): a destructive rebuild would also wipe this + // 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 = 10, exportSchema = true, ) @TypeConverters(MinstrelTypeConverters::class) @@ -92,3 +103,25 @@ 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") + } +} + +/** + * v9 → v10: the gain columns (#5000). Rows synced before this carry no gains, + * and the sync is incremental, so it would never re-send them: the cursor goes + * back to 0 and the next sync is a full one, upserting every row in place. + */ +val MIGRATION_9_10: Migration = object : Migration(9, 10) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE cached_tracks ADD COLUMN trackGain REAL") + db.execSQL("ALTER TABLE cached_tracks ADD COLUMN trackPeak REAL") + db.execSQL("ALTER TABLE cached_albums ADD COLUMN albumGain REAL") + db.execSQL("ALTER TABLE cached_albums ADD COLUMN albumPeak REAL") + db.execSQL("UPDATE sync_metadata SET cursor = 0") + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/DatabaseModule.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/DatabaseModule.kt index c5c58df4..db7e4f06 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/DatabaseModule.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/DatabaseModule.kt @@ -37,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, MIGRATION_9_10) .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/dao/CachedTrackDao.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedTrackDao.kt index a65fa09e..da1894dc 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedTrackDao.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedTrackDao.kt @@ -38,6 +38,27 @@ interface CachedTrackDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertAll(rows: List) + /** + * ReplayGain values for [ids] (M464 #5000): the track's own from its row, + * the album's from its album row. A track not in the cache has no row. + */ + @Query( + "SELECT t.id AS id, t.trackGain AS trackGain, t.trackPeak AS trackPeak, " + + "a.albumGain AS albumGain, a.albumPeak AS albumPeak " + + "FROM cached_tracks t LEFT JOIN cached_albums a ON a.id = t.albumId " + + "WHERE t.id IN (:ids)", + ) + suspend fun replayGains(ids: List): List + @Query("DELETE FROM cached_tracks WHERE id IN (:ids)") suspend fun deleteByIds(ids: List) } + +/** One row of [CachedTrackDao.replayGains]. */ +data class CachedReplayGain( + val id: String, + val trackGain: Float?, + val trackPeak: Float?, + val albumGain: Float?, + val albumPeak: Float?, +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/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/db/entities/CachedAlbumEntity.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedAlbumEntity.kt index 05274bda..b2a22dfa 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedAlbumEntity.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedAlbumEntity.kt @@ -18,5 +18,8 @@ data class CachedAlbumEntity( val releaseDate: String? = null, val coverPath: String? = null, val mbid: String? = null, + // ReplayGain 2.0 album values (M464); null until every track is measured. + val albumGain: Float? = null, + val albumPeak: Float? = null, val fetchedAt: Instant = Clock.System.now(), ) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedTrackEntity.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedTrackEntity.kt index d8fe1624..f3cff52d 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedTrackEntity.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedTrackEntity.kt @@ -26,5 +26,9 @@ data class CachedTrackEntity( val fileFormat: String? = null, val genre: String? = null, val missing: Boolean = false, + // ReplayGain 2.0 track values (M464), kept so cached audio levels + // offline. Null until the server has measured the track. + val trackGain: Float? = null, + val trackPeak: Float? = null, val fetchedAt: Instant = Clock.System.now(), ) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/mutations/MutationQueue.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/mutations/MutationQueue.kt index 84a0bdf0..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 @@ -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" } /** @@ -85,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, @@ -177,6 +184,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/cache/sync/SyncController.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/sync/SyncController.kt index a7c8620b..ba290b9d 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/sync/SyncController.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/sync/SyncController.kt @@ -206,6 +206,8 @@ private fun SyncAlbumWire.toEntity(): CachedAlbumEntity = CachedAlbumEntity( releaseDate = releaseDate, coverPath = coverArtPath, mbid = mbid, + albumGain = albumGain, + albumPeak = albumPeak, ) private fun SyncTrackWire.toEntity(): CachedTrackEntity = CachedTrackEntity( @@ -220,4 +222,6 @@ private fun SyncTrackWire.toEntity(): CachedTrackEntity = CachedTrackEntity( fileFormat = fileFormat, genre = genre, missing = missing, + trackGain = trackGain, + trackPeak = trackPeak, ) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/ReplayGainWire.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/ReplayGainWire.kt new file mode 100644 index 00000000..b58a9254 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/ReplayGainWire.kt @@ -0,0 +1,18 @@ +package com.fabledsword.minstrel.models.wire + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** One track's entry in `GET /api/tracks/replay-gain` (#4997). Decode-only. */ +@Serializable +data class ReplayGainWire( + @SerialName("track_gain") val trackGain: Float? = null, + @SerialName("track_peak") val trackPeak: Float? = null, + @SerialName("album_gain") val albumGain: Float? = null, + @SerialName("album_peak") val albumPeak: Float? = null, +) + +@Serializable +data class ReplayGainResponseWire( + val items: Map = emptyMap(), +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/SyncResponseWire.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/SyncResponseWire.kt index eb107be8..67e75e61 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/SyncResponseWire.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/SyncResponseWire.kt @@ -28,6 +28,10 @@ data class SyncAlbumWire( @SerialName("release_date") val releaseDate: String? = null, @SerialName("cover_art_path") val coverArtPath: String? = null, val mbid: String? = null, + // The album's ReplayGain 2.0 values (#4997): dB to -18 LUFS and a linear + // peak. Null until every track on the album is measured. + @SerialName("album_gain") val albumGain: Float? = null, + @SerialName("album_peak") val albumPeak: Float? = null, ) @Serializable @@ -51,6 +55,9 @@ data class SyncTrackWire( // its tracks stay playable, which is the correct reading of "this server // has nothing to say about missing files". val missing: Boolean = false, + // The track's ReplayGain 2.0 values (#4997); null until it is measured. + @SerialName("track_gain") val trackGain: Float? = null, + @SerialName("track_peak") val trackPeak: Float? = null, ) /** diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt index 1556bb42..af40b975 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt @@ -93,6 +93,8 @@ class MinstrelForwardingPlayer( * one. Diagnostics-only; see [TransportObservation]. */ val onTransport: (TransportObservation) -> Unit = {}, + /** The renderer's 1-based queue position, every poll. */ + val onRendererTrack: (trackNumber: Int) -> Unit = {}, ) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) @@ -622,6 +624,7 @@ class MinstrelForwardingPlayer( trackNumber = info.track, ) syncLocalCursorToRemote(sonosTrack = info.track, trackUri = info.trackUri) + events.onRendererTrack(info.track) val transport = active.avTransport.getTransportInfo() when (transport.state) { TransportState.PLAYING -> { diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelPlayerService.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelPlayerService.kt index 59741113..f0d21efa 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelPlayerService.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelPlayerService.kt @@ -12,8 +12,10 @@ import androidx.media3.session.SessionCommand import com.fabledsword.minstrel.MainActivity import com.fabledsword.minstrel.likes.data.LikesRepository import com.fabledsword.minstrel.likes.data.LikesRepository.Companion.ENTITY_TRACK +import com.fabledsword.minstrel.settings.data.NormalizationRepository import com.google.common.collect.ImmutableList import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -25,6 +27,7 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.launch +import timber.log.Timber import javax.inject.Inject /** @@ -66,6 +69,8 @@ class MinstrelPlayerService : MediaSessionService() { @Inject lateinit var likesRepository: LikesRepository + @Inject lateinit var normalizationRepository: NormalizationRepository + private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) private var mediaSession: MediaSession? = null @@ -82,6 +87,21 @@ class MinstrelPlayerService : MediaSessionService() { .build() mediaSession = session serviceScope.launch { observeLikeState(session, player) } + serviceScope.launch { refreshNormalization() } + } + + // Takes up a leveling preference changed on another device (M464 #5000). + // Offline, the device's copy stands, which is the one playback reads. + private suspend fun refreshNormalization() { + try { + normalizationRepository.refresh() + } catch (e: CancellationException) { + throw e + } catch ( + @Suppress("TooGenericExceptionCaught") e: Throwable, + ) { + Timber.d(e, "normalization refresh skipped") + } } /** diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt index 44c1b01c..87f09513 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt @@ -14,6 +14,8 @@ import androidx.media3.session.MediaController import androidx.media3.session.SessionToken import com.fabledsword.minstrel.di.ApplicationScope import com.fabledsword.minstrel.models.TrackRef +import com.fabledsword.minstrel.player.gain.GainAudioProcessor +import com.fabledsword.minstrel.player.gain.ReplayGainStore import com.fabledsword.minstrel.playlists.data.PlaylistsRepository import com.fabledsword.minstrel.playlists.data.toPlayableTrackRefs import com.fabledsword.minstrel.shared.resolveServerUrl @@ -69,6 +71,7 @@ class PlayerController @Inject constructor( private val playerFactory: PlayerFactory, private val activeUpnpHolder: com.fabledsword.minstrel.player.output.ActiveUpnpHolder, private val remoteState: RemotePlayerState, + private val replayGains: ReplayGainStore, ) { /** @@ -277,6 +280,11 @@ class PlayerController @Inject constructor( queueRefs = playable.tracks val items = playable.tracks.map { it.toMediaItem(source) } val startIndex = playable.initialIndex + // Gains for the first tracks, so the first one levels from its first + // sample rather than ramping in once the lookup lands. + replayGains.request( + playable.tracks.drop(startIndex).take(GAIN_PREFETCH).map { it.id }, + ) // Drift #562 cold-boot resume calls this from a non-Main suspend // context after awaitReady() unblocks (ResumeController launches // on Dispatchers.Default by the time it reaches us). MediaController @@ -792,7 +800,13 @@ class PlayerController @Inject constructor( // scrubber a real total even when the wrapped ExoPlayer is // paused under UPnP (it never probes a duration in that state). if (durationSec > 0) setDurationMs(durationSec.toLong() * MS_PER_SECOND) - if (source != null) setExtras(sourceExtras(source)) + // Album and track position let the gain processor tell an + // album played in order from a mix (M464 #5000). + trackNumber?.let { setTrackNumber(it) } + discNumber?.let { setDiscNumber(it) } + val extras = GainAudioProcessor.albumExtras(albumId) + if (source != null) extras.putAll(sourceExtras(source)) + setExtras(extras) // Point the notification / lock-screen art at the SAME album // cover the in-app surfaces use (TrackRef.coverUrl -> // /api/albums/{id}/cover). Without this, Media3 falls back to @@ -860,6 +874,7 @@ class PlayerController @Inject constructor( const val MINSTREL_SOURCE_KEY: String = "minstrel_source" private const val MS_PER_SECOND = 1_000L private const val MAX_INTERPOLATION_DRIFT_MS = 5_000L + private const val GAIN_PREFETCH = 20 } } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerFactory.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerFactory.kt index 741c024c..2cc0cc0e 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerFactory.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerFactory.kt @@ -4,6 +4,7 @@ import android.content.Context import androidx.media3.common.AudioAttributes import androidx.media3.common.C import androidx.media3.common.Player +import androidx.media3.common.audio.AudioProcessor import androidx.media3.common.util.BitmapLoader import androidx.media3.database.StandaloneDatabaseProvider import androidx.media3.datasource.DataSourceBitmapLoader @@ -12,11 +13,18 @@ import androidx.media3.datasource.cache.CacheDataSource import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor import androidx.media3.datasource.cache.SimpleCache import androidx.media3.datasource.okhttp.OkHttpDataSource +import androidx.media3.exoplayer.DefaultRenderersFactory import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.audio.AudioSink +import androidx.media3.exoplayer.audio.DefaultAudioSink import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.session.CacheBitmapLoader +import com.fabledsword.minstrel.auth.AuthStore import com.fabledsword.minstrel.cache.audiocache.CacheConfig +import com.fabledsword.minstrel.player.gain.GainAudioProcessor +import com.fabledsword.minstrel.player.gain.ReplayGainStore import com.fabledsword.minstrel.player.output.ActiveUpnpHolder +import com.fabledsword.minstrel.player.output.SonosQueueLoader import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow @@ -56,6 +64,9 @@ class PlayerFactory @Inject constructor( private val activeUpnpHolder: ActiveUpnpHolder, private val remoteState: RemotePlayerState, private val serverHealth: com.fabledsword.minstrel.connectivity.NetworkStatusController, + private val authStore: AuthStore, + private val replayGains: ReplayGainStore, + private val sonosQueue: SonosQueueLoader, ) { private val cacheDir: File = File(context.cacheDir, "audio_cache").apply { mkdirs() } @@ -120,6 +131,7 @@ class PlayerFactory @Inject constructor( onStalled = { trackId -> stallEventsInternal.tryEmit(trackId) }, onQueueTruncated = { queueRepairInternal.tryEmit(Unit) }, onTransport = { transportInternal.tryEmit(it) }, + onRendererTrack = { sonosQueue.onRendererTrack(it) }, ), ) } @@ -142,7 +154,28 @@ class PlayerFactory @Inject constructor( val mediaSourceFactory = DefaultMediaSourceFactory(context) .setDataSourceFactory(cacheDataSource) - return ExoPlayer.Builder(context) + // Loudness normalization (M464 #5000) runs inside the audio sink so + // each track's gain starts on its first sample. Audio offload would + // bypass the sink's processors; ExoPlayer leaves it off unless asked + // (TrackSelectionParameters.audioOffloadPreferences), and nothing here + // asks, so leveling always applies. + val gainProcessor = GainAudioProcessor( + prefs = { authStore.normalization.value }, + store = replayGains, + ) + val renderersFactory = object : DefaultRenderersFactory(context) { + override fun buildAudioSink( + context: Context, + enableFloatOutput: Boolean, + enableAudioOutputPlaybackParams: Boolean, + ): AudioSink = DefaultAudioSink.Builder(context) + .setEnableFloatOutput(enableFloatOutput) + .setEnableAudioOutputPlaybackParameters(enableAudioOutputPlaybackParams) + .setAudioProcessors(arrayOf(gainProcessor)) + .build() + } + + val exo = ExoPlayer.Builder(context, renderersFactory) .setMediaSourceFactory(mediaSourceFactory) .setAudioAttributes( AudioAttributes.Builder() @@ -153,6 +186,17 @@ class PlayerFactory @Inject constructor( ) .setHandleAudioBecomingNoisy(true) .build() + // The processor finds a track's neighbours in play order, which + // depends on shuffle mode. + gainProcessor.shuffleEnabled = exo.shuffleModeEnabled + exo.addListener( + object : Player.Listener { + override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) { + gainProcessor.shuffleEnabled = shuffleModeEnabled + } + }, + ) + return exo } /** diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/StreamTokenProvider.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/StreamTokenProvider.kt index ffff44bf..45e8b281 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/StreamTokenProvider.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/StreamTokenProvider.kt @@ -19,6 +19,13 @@ import javax.inject.Singleton class StreamTokenProvider @Inject constructor(retrofit: Retrofit) { private val api: CastApi = retrofit.create() - suspend fun mint(trackId: String): StreamTokenResponse = - api.streamToken(StreamTokenRequest(trackId = trackId)) + /** + * Mints a URL for a speaker, leveled when the user's setting calls for + * it (M464 #5002). The server answers with the plain stream when it does + * not, so every speaker URL asks. + */ + suspend fun mint(trackId: String, asAlbum: Boolean = false, prerender: Boolean = false): StreamTokenResponse = + api.streamToken( + StreamTokenRequest(trackId = trackId, level = true, asAlbum = asAlbum, prerender = prerender), + ) } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/gain/GainAudioProcessor.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/gain/GainAudioProcessor.kt new file mode 100644 index 00000000..8baa70d0 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/gain/GainAudioProcessor.kt @@ -0,0 +1,139 @@ +package com.fabledsword.minstrel.player.gain + +import android.os.Bundle +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.common.Timeline +import androidx.media3.common.audio.AudioProcessor +import androidx.media3.common.audio.BaseAudioProcessor +import com.fabledsword.minstrel.settings.data.NormalizationBoost +import com.fabledsword.minstrel.settings.data.NormalizationMode +import com.fabledsword.minstrel.settings.data.NormalizationPrefs +import java.nio.ByteBuffer + +/** + * Applies each track's loudness gain inside ExoPlayer's audio sink (M464 + * #5000), so the level changes on the exact sample a track starts, gapless + * transitions included. `Player.setVolume` could do neither: it stops at 1, + * and set from a transition callback it lands about two seconds late + * because the next track is already buffered (androidx/media#418). + * + * Media3 1.11 flushes the sink's processors at every item boundary with the + * playlist [Timeline] and the new item's period, which is how this knows + * which track it is processing and who its neighbours are. + * + * Boosts above unity are held under -1 dBFS by a peak limiter when the user + * chose one. In headroom mode the gain already stops short of the track's + * true peak, and the final clamp only guards against a bad measurement. + * + * Runs on the playback thread. [prefs] and [store] are read per buffer, so a + * changed preference or a gain that arrives mid-track applies at once, + * through a short ramp rather than a step. + */ +class GainAudioProcessor( + private val prefs: () -> NormalizationPrefs, + private val store: ReplayGainStore, +) : BaseAudioProcessor() { + + /** Mirrors the player's shuffle mode, so neighbours are found in play order. */ + @Volatile var shuffleEnabled: Boolean = false + + private var currentId: String? = null + private var asAlbum = false + private val stage = GainStage() + + override fun onConfigure(inputAudioFormat: AudioProcessor.AudioFormat): AudioProcessor.AudioFormat = + when (inputAudioFormat.encoding) { + C.ENCODING_PCM_16BIT, C.ENCODING_PCM_FLOAT -> inputAudioFormat + else -> AudioProcessor.AudioFormat.NOT_SET + } + + override fun onFlush(streamMetadata: AudioProcessor.StreamMetadata) { + identify(streamMetadata) + // A new stream starts at its own level: ramping in from the previous + // track's gain would swell or dip its first moments. + stage.reset(inputAudioFormat.sampleRate, inputAudioFormat.channelCount, targetGain()) + } + + override fun onReset() { + currentId = null + asAlbum = false + } + + override fun queueInput(inputBuffer: ByteBuffer) { + val size = inputBuffer.remaining() + if (size == 0) return + val out = replaceOutputBuffer(size) + val p = prefs() + val target = targetGain(p) + val limiting = p.mode != NormalizationMode.OFF && p.boost == NormalizationBoost.LIMITER + if (stage.isUnity(target, limiting)) { + out.put(inputBuffer) + } else { + stage.process( + input = inputBuffer, + out = out, + channels = inputAudioFormat.channelCount, + isFloat = inputAudioFormat.encoding == C.ENCODING_PCM_FLOAT, + target = target, + limiting = limiting, + ) + } + out.flip() + } + + private fun targetGain(p: NormalizationPrefs = prefs()): Float { + val id = currentId ?: return 1f + return GainMath.dbToLinear(GainMath.gainDb(p, store.get(id), asAlbum)) + } + + // Which track this stream is, and whether it is playing as part of its + // album. Also asks the store for the gains of the tracks coming up, so + // each is known before it starts. + private fun identify(meta: AudioProcessor.StreamMetadata) { + currentId = null + asAlbum = false + val uid = meta.periodUid + if (uid == null || meta.timeline.isEmpty) return + val timeline = meta.timeline + val index = timeline.getPeriodByUid(uid, Timeline.Period()).windowIndex + val window = Timeline.Window() + fun itemAt(i: Int): MediaItem? = + if (i == C.INDEX_UNSET) null else timeline.getWindow(i, window).mediaItem + fun nextOf(i: Int) = timeline.getNextWindowIndex(i, Player.REPEAT_MODE_OFF, shuffleEnabled) + + val cur = itemAt(index) + val prev = itemAt(timeline.getPreviousWindowIndex(index, Player.REPEAT_MODE_OFF, shuffleEnabled)) + val next = itemAt(nextOf(index)) + if (cur != null) { + currentId = cur.mediaId + asAlbum = GainMath.playingAsAlbum(prev?.albumPosition(), cur.albumPosition(), next?.albumPosition()) + } + + val upcoming = mutableListOf() + var i = index + while (i != C.INDEX_UNSET && upcoming.size < LOOKAHEAD) { + itemAt(i)?.let { upcoming += it.mediaId } + i = nextOf(i) + } + store.request(upcoming) + } + + companion object { + /** MediaMetadata extras key holding the track's album id. */ + const val EXTRA_ALBUM_ID = "minstrel.album_id" + + private const val LOOKAHEAD = 20 + + /** Puts what [albumPosition] reads into a MediaItem's metadata extras. */ + fun albumExtras(albumId: String, into: Bundle = Bundle()): Bundle = + into.apply { putString(EXTRA_ALBUM_ID, albumId) } + } +} + +private fun MediaItem.albumPosition(): AlbumPosition = AlbumPosition( + albumId = mediaMetadata.extras?.getString(GainAudioProcessor.EXTRA_ALBUM_ID), + discNumber = mediaMetadata.discNumber, + trackNumber = mediaMetadata.trackNumber, +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/gain/GainMath.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/gain/GainMath.kt new file mode 100644 index 00000000..b72fee8e --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/gain/GainMath.kt @@ -0,0 +1,96 @@ +package com.fabledsword.minstrel.player.gain + +import com.fabledsword.minstrel.settings.data.NormalizationBoost +import com.fabledsword.minstrel.settings.data.NormalizationMode +import com.fabledsword.minstrel.settings.data.NormalizationPrefs +import kotlin.math.log10 +import kotlin.math.min +import kotlin.math.pow + +/** + * One track's ReplayGain 2.0 values from the server (#4997): dB to the + * -18 LUFS reference, and linear true peaks. A null field has not been + * measured yet. + */ +data class ReplayGain( + val trackGain: Float?, + val trackPeak: Float?, + val albumGain: Float?, + val albumPeak: Float?, +) { + companion object { + val NONE = ReplayGain(null, null, null, null) + } +} + +/** Where a queue item sits in its album, for the auto-mode album rule. */ +data class AlbumPosition(val albumId: String?, val discNumber: Int?, val trackNumber: Int?) + +/** + * Loudness-normalization math (M464 #5000). The same rules as the web + * player's `web/src/lib/player/gain.ts`, so a track levels the same on + * every device. + */ +object GainMath { + private const val REFERENCE_LUFS = -18 + + /** Headroom mode raises a quiet track only until its true peak reaches this. */ + const val PEAK_CEILING_DBTP = -1f + + /** + * No track is raised more than this, whatever its measurement says: a + * near-silent track would otherwise come out as amplified noise. + */ + const val MAX_BOOST_DB = 12f + + private const val DISC_STRIDE = 1000 + + // Amplitude decibels: 20 dB per factor of ten. + private const val DB_PER_DECADE = 20f + + /** + * The gain to apply, in dB. 0 when leveling is off or the track has not + * been measured: an unmeasured track plays as mastered. + */ + fun gainDb(prefs: NormalizationPrefs, g: ReplayGain?, asAlbum: Boolean): Float { + if (prefs.mode == NormalizationMode.OFF || g == null) return 0f + val wantAlbum = prefs.mode == NormalizationMode.ALBUM || + (prefs.mode == NormalizationMode.AUTO && asAlbum) + // Album gain falls back to track gain while the album is still being + // measured; track gain never falls back to album gain. + val useAlbum = wantAlbum && g.albumGain != null + val gain = if (useAlbum) g.albumGain else g.trackGain + val peak = if (useAlbum) g.albumPeak else g.trackPeak + return gain?.let { leveled(prefs, it, peak) } ?: 0f + } + + private fun leveled(prefs: NormalizationPrefs, gain: Float, peak: Float?): Float { + var db = gain + (prefs.targetLufs - REFERENCE_LUFS) + if (prefs.boost == NormalizationBoost.HEADROOM && peak != null && peak > 0f) { + db = min(db, PEAK_CEILING_DBTP - DB_PER_DECADE * log10(peak)) + } + return min(db, MAX_BOOST_DB) + } + + /** + * Whether the current item is being played as part of its album, in + * order: a neighbour in play order is from the same album and sits on + * the right side of it. That is when album gain keeps the album's own + * dynamics (a quiet intro stays quiet); anywhere else track gain levels + * the mix. + */ + fun playingAsAlbum(prev: AlbumPosition?, cur: AlbumPosition, next: AlbumPosition?): Boolean { + val curOrder = order(cur) + if (cur.albumId == null || curOrder == null) return false + val prevOrder = prev?.takeIf { it.albumId == cur.albumId }?.let { order(it) } + val nextOrder = next?.takeIf { it.albumId == cur.albumId }?.let { order(it) } + return (prevOrder != null && prevOrder < curOrder) || (nextOrder != null && nextOrder > curOrder) + } + + // Disc-major track order. A track with no number has no place in the + // order and is never evidence of album play. + private fun order(p: AlbumPosition): Int? = + p.trackNumber?.let { (p.discNumber ?: 1) * DISC_STRIDE + it } + + fun dbToLinear(db: Float): Float = 10f.pow(db / DB_PER_DECADE) +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/gain/GainStage.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/gain/GainStage.kt new file mode 100644 index 00000000..d8d40c12 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/gain/GainStage.kt @@ -0,0 +1,82 @@ +package com.fabledsword.minstrel.player.gain + +import java.nio.ByteBuffer +import kotlin.math.abs +import kotlin.math.exp +import kotlin.math.max +import kotlin.math.roundToInt + +/** + * The sample arithmetic of [GainAudioProcessor], apart from Media3 so it can + * be tested on the JVM: a gain that ramps toward its target, then an + * optional peak limiter, then a clamp to full scale. Interleaved 16-bit or + * float PCM in, the same format out. + */ +internal class GainStage { + private var gain = 1f + private var envelope = 0f + private var rampCoeff = 1f + private var releaseCoeff = 0f + private var frame = FloatArray(2) + + /** Sets the sample rate and channel count, and starts the gain at [startGain]. */ + fun reset(sampleRate: Int, channels: Int, startGain: Float) { + val rate = sampleRate.coerceAtLeast(1).toFloat() + rampCoeff = 1f - exp(-1f / (RAMP_SECONDS * rate)) + releaseCoeff = exp(-1f / (RELEASE_SECONDS * rate)) + if (frame.size < channels) frame = FloatArray(channels) + gain = startGain + envelope = 0f + } + + /** True when processing would copy the input unchanged. */ + fun isUnity(target: Float, limiting: Boolean): Boolean = gain == 1f && target == 1f && !limiting + + /** + * Processes every whole frame of [input] into [out]. A trailing partial + * frame is dropped, as Media3's own processors do. + */ + @Suppress("LongParameterList") // the PCM layout is four facts; a holder type would only rename them + fun process( + input: ByteBuffer, + out: ByteBuffer, + channels: Int, + isFloat: Boolean, + target: Float, + limiting: Boolean, + ) { + val bytesPerFrame = channels * if (isFloat) FLOAT_BYTES else PCM16_BYTES + val frames = input.remaining() / bytesPerFrame + repeat(frames) { + gain += (target - gain) * rampCoeff + var peak = 0f + for (c in 0 until channels) { + val s = (if (isFloat) input.float else input.short / PCM16_SCALE) * gain + frame[c] = s + peak = max(peak, abs(s)) + } + var reduce = 1f + if (limiting) { + envelope = max(peak, envelope * releaseCoeff) + if (envelope > LIMIT_CEILING) reduce = LIMIT_CEILING / envelope + } + for (c in 0 until channels) { + val v = (frame[c] * reduce).coerceIn(-1f, 1f) + if (isFloat) out.putFloat(v) else out.putShort((v * PCM16_MAX).roundToInt().toShort()) + } + } + input.position(input.limit()) + } + + companion object { + /** -1 dBFS, the ceiling the web player's limiter holds too. */ + const val LIMIT_CEILING = 0.8913f + + private const val RAMP_SECONDS = 0.05f + private const val RELEASE_SECONDS = 0.25f + private const val PCM16_SCALE = 32768f + private const val PCM16_MAX = 32767f + private const val PCM16_BYTES = 2 + private const val FLOAT_BYTES = 4 + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/gain/ReplayGainStore.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/gain/ReplayGainStore.kt new file mode 100644 index 00000000..7d6400f3 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/gain/ReplayGainStore.kt @@ -0,0 +1,140 @@ +package com.fabledsword.minstrel.player.gain + +import com.fabledsword.minstrel.api.endpoints.ReplayGainApi +import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao +import com.fabledsword.minstrel.connectivity.NetworkStatusController +import com.fabledsword.minstrel.connectivity.ServerHealth +import com.fabledsword.minstrel.di.ApplicationScope +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import retrofit2.Retrofit +import retrofit2.create +import timber.log.Timber +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject +import javax.inject.Singleton + +/** + * The gains the player levels by (M464 #5000), looked up per track and + * held for the life of the process. The audio thread reads them with [get]; + * [request] fills them in the background. + * + * Sources, most to least preferred: + * 1. the library cache, synced with the gains, so cached audio levels + * offline; + * 2. the server's replay-gain lookup, for a track not cached yet or + * measured since the last sync; + * 3. none: the track plays as mastered, and the player picks the gain up + * the moment it lands. + */ +@Singleton +class ReplayGainStore internal constructor( + private val scope: CoroutineScope, + private val trackDao: CachedTrackDao, + private val api: ReplayGainApi, + private val serverHealth: () -> ServerHealth, + private val clock: () -> Long, +) { + @Inject constructor( + @ApplicationScope scope: CoroutineScope, + trackDao: CachedTrackDao, + retrofit: Retrofit, + network: NetworkStatusController, + ) : this( + scope = scope, + trackDao = trackDao, + api = retrofit.create(), + serverHealth = { network.state.value }, + clock = System::currentTimeMillis, + ) + + private val gains = ConcurrentHashMap() + private val inflight: MutableSet = ConcurrentHashMap.newKeySet() + + // When the server last said it had nothing for a track. Not cached for + // good: the backfill may measure it minutes later. + private val missedAt = ConcurrentHashMap() + + /** The track's gains, or null while they are unknown. Safe from any thread. */ + fun get(trackId: String): ReplayGain? = gains[trackId] + + /** Starts loading gains for any of [trackIds] not already known or loading. */ + fun request(trackIds: Collection) { + val now = clock() + val wanted = trackIds.filter { id -> + !gains.containsKey(id) && + (missedAt[id]?.let { now - it > MISS_RETRY_MS } ?: true) && + inflight.add(id) + } + if (wanted.isEmpty()) return + scope.launch { + try { + load(wanted) + } finally { + inflight.removeAll(wanted.toSet()) + } + } + } + + internal suspend fun load(ids: List) { + fromCache(ids) + val rest = ids.filter { !gains.containsKey(it) } + val health = serverHealth() + if (rest.isEmpty() || health == ServerHealth.Offline || health == ServerHealth.ServerDown) return + for (batch in rest.chunked(MAX_IDS_PER_REQUEST)) { + // A failed lookup leaves the batch unknown; the next request asks again. + if (!fromServer(batch)) return + } + } + + private suspend fun fromCache(ids: List) { + val rows = try { + trackDao.replayGains(ids) + } catch (e: CancellationException) { + throw e + } catch ( + @Suppress("TooGenericExceptionCaught") e: Throwable, + ) { + Timber.w(e, "replay gain: cache read failed") + emptyList() + } + for (row in rows) { + // A cached row without a track gain is not an answer: the server + // may have measured the track since this device last synced. + if (row.trackGain == null) continue + gains[row.id] = ReplayGain(row.trackGain, row.trackPeak, row.albumGain, row.albumPeak) + } + } + + /** Asks the server about [batch]; false when it could not be asked. */ + private suspend fun fromServer(batch: List): Boolean { + val res = try { + withTimeoutOrNull(REQUEST_TIMEOUT_MS) { api.getReplayGain(batch.joinToString(",")) } + } catch (e: CancellationException) { + throw e + } catch ( + @Suppress("TooGenericExceptionCaught") e: Throwable, + ) { + Timber.w(e, "replay gain: lookup failed") + null + } ?: return false + val now = clock() + for (id in batch) { + val w = res.items[id] + if (w == null) { + missedAt[id] = now + } else { + gains[id] = ReplayGain(w.trackGain, w.trackPeak, w.albumGain, w.albumPeak) + } + } + return true + } + + private companion object { + const val MAX_IDS_PER_REQUEST = 200 // the endpoint's limit + const val REQUEST_TIMEOUT_MS = 10_000L + const val MISS_RETRY_MS = 5 * 60_000L + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/SonosQueueLoader.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/SonosQueueLoader.kt index 950b5024..355abbfd 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/output/SonosQueueLoader.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/SonosQueueLoader.kt @@ -4,6 +4,8 @@ import com.fabledsword.minstrel.di.ApplicationScope import com.fabledsword.minstrel.models.TrackRef import com.fabledsword.minstrel.player.RemotePlayerState import com.fabledsword.minstrel.player.StreamTokenProvider +import com.fabledsword.minstrel.player.gain.AlbumPosition +import com.fabledsword.minstrel.player.gain.GainMath import com.fabledsword.minstrel.player.output.upnp.AVTransportClient import com.fabledsword.minstrel.player.output.upnp.SoapFaultException import com.fabledsword.minstrel.player.output.upnp.bareUdn @@ -24,6 +26,11 @@ import javax.inject.Singleton * with its own failure modes, and it had grown large enough to hide one: * every write here is a SOAP call that can fail individually, and until * [verifyQueueLength] nothing ever read the result back. + * + * Every URL sent is a leveled one (M464 #5002): the server renders the track + * at the user's loudness gain, or hands back the plain stream when leveling + * is off. Renders are slow enough to matter, so the track playing and the + * one after it are rendered ahead; the rest render when the speaker asks. */ @Singleton class SonosQueueLoader @Inject constructor( @@ -32,6 +39,13 @@ class SonosQueueLoader @Inject constructor( private val activeUpnpHolder: ActiveUpnpHolder, private val remoteState: RemotePlayerState, ) { + // The queue as last sent to the renderer, for looking up the track after + // the one it is playing. + @Volatile private var sent: List = emptyList() + + // The renderer track the next one was last prerendered for. + @Volatile private var prerenderedAfter = 0 + suspend fun load( transport: AVTransportClient, route: OutputRoute, @@ -46,8 +60,10 @@ class SonosQueueLoader @Inject constructor( "UPnP select: add %d initial tracks (currentIndex=%d, totalQueue=%d)", initialBatch.size, currentIndex, queue.size, ) - initialBatch.forEachIndexed { idx, ref -> - val token = streamTokens.mint(ref.id) + sent = queue + prerenderedAfter = currentIndex + 1 + initialBatch.indices.forEach { idx -> + val token = mint(queue, idx, prerender = idx == currentIndex) transport.addURIToQueue( uri = token.url, mime = token.mime, @@ -64,12 +80,12 @@ class SonosQueueLoader @Inject constructor( Timber.w("UPnP select: Play") transport.play() Timber.w("UPnP select: initial done; backgrounding remainder") - val remaining = queue.drop(initialEnd) // Verify even when there is no tail to append: the initial batch is // sent the same way and can be dropped the same way. scope.launch { - if (remaining.isNotEmpty()) { - extendQueueOnSonos(transport, route, remaining, initialEnd) + prerender(queue, currentIndex + 1) + if (initialEnd < queue.size) { + extendQueueOnSonos(transport, route, queue, initialEnd) } verifyQueueLength(transport, route, queue) } @@ -84,15 +100,16 @@ class SonosQueueLoader @Inject constructor( private suspend fun extendQueueOnSonos( transport: AVTransportClient, route: OutputRoute, - tracks: List, + queue: List, startPosition: Int, ) { + val count = queue.size - startPosition Timber.w( "UPnP extend: appending %d tracks starting at position %d", - tracks.size, startPosition + 1, + count, startPosition + 1, ) - val succeeded = appendTracksToQueue(transport, route, tracks, startPosition) - Timber.w("UPnP extend: done (%d / %d appended)", succeeded, tracks.size) + val succeeded = appendTracksToQueue(transport, route, queue, startPosition) + Timber.w("UPnP extend: done (%d / %d appended)", succeeded, count) } /** @@ -136,18 +153,17 @@ class SonosQueueLoader @Inject constructor( Timber.w("UPnP verify: renderer holds %d tracks, queue intact", nrTracks) return } - val missing = fullQueue.drop(nrTracks) Timber.w( "UPnP verify: %s holds %d of %d tracks; appending %d missing (round %d)", - route.name, nrTracks, fullQueue.size, missing.size, round + 1, + route.name, nrTracks, fullQueue.size, fullQueue.size - nrTracks, round + 1, ) - appendTracksToQueue(transport, route, missing, nrTracks) + appendTracksToQueue(transport, route, fullQueue, nrTracks) } Timber.w("UPnP verify: gave up repairing queue length on %s", route.name) } /** - * Append [tracks] at [startPosition] (0-based), returning how many landed. + * Append [queue] from [startPosition] (0-based) on, returning how many landed. * Tolerates individual AddURIToQueue failures — log and continue so some * tracks loaded is better than zero tracks loaded — and stops early after * [EXTEND_ABORT_AFTER_FAILURES] consecutive ones. @@ -155,20 +171,20 @@ class SonosQueueLoader @Inject constructor( private suspend fun appendTracksToQueue( transport: AVTransportClient, route: OutputRoute, - tracks: List, + queue: List, startPosition: Int, ): Int { var consecutiveFailures = 0 var succeeded = 0 var aborted = false - for ((i, ref) in tracks.withIndex()) { + for (i in 0 until queue.size - startPosition) { if (aborted) break if (activeUpnpHolder.active.value?.routeId != route.id) { Timber.w("UPnP extend: cancelled at offset %d (route changed)", i) aborted = true } else { val outcome = runCatching { - val token = streamTokens.mint(ref.id) + val token = mint(queue, startPosition + i) transport.addURIToQueue( uri = token.url, mime = token.mime, @@ -220,6 +236,7 @@ class SonosQueueLoader @Inject constructor( newQueue: List, ): Boolean { val newIds = newQueue.map { it.id } + sent = newQueue if (oldIds == newIds) return true val prefixLen = commonPrefixLength(oldIds, newIds) val suffixLen = commonSuffixLength( @@ -271,8 +288,7 @@ class SonosQueueLoader @Inject constructor( prefixLen + 1, ) for (i in 0 until addedCount) { - val ref = newQueue[prefixLen + i] - val token = streamTokens.mint(ref.id) + val token = mint(newQueue, prefixLen + i) transport.addURIToQueue( uri = token.url, mime = token.mime, @@ -283,6 +299,27 @@ class SonosQueueLoader @Inject constructor( } } + /** + * Called on every poll with the renderer's 1-based track number. When it + * moves, the track after it is rendered ahead, so the speaker's fetch of + * it finds the render ready. + */ + fun onRendererTrack(trackNumber: Int) { + if (trackNumber <= 0 || trackNumber == prerenderedAfter) return + prerenderedAfter = trackNumber + val queue = sent + scope.launch { prerender(queue, trackNumber) } + } + + private suspend fun prerender(queue: List, index: Int) { + if (index !in queue.indices) return + runCatching { mint(queue, index, prerender = true) } + .onFailure { Timber.w(it, "UPnP prerender failed for %s", queue[index].id) } + } + + private suspend fun mint(queue: List, index: Int, prerender: Boolean = false) = + streamTokens.mint(queue[index].id, asAlbum = playingAsAlbum(queue, index), prerender = prerender) + private fun commonPrefixLength(a: List, b: List): Int { val limit = minOf(a.size, b.size) for (i in 0 until limit) { @@ -299,18 +336,33 @@ class SonosQueueLoader @Inject constructor( return limit } - private companion object { + companion object { + /** + * Whether [queue]'s track at [index] plays among its album in order, + * judged by its neighbours in the renderer's queue, which plays + * straight through. + */ + internal fun playingAsAlbum(queue: List, index: Int): Boolean = + GainMath.playingAsAlbum( + prev = queue.getOrNull(index - 1)?.let(::position), + cur = position(queue[index]), + next = queue.getOrNull(index + 1)?.let(::position), + ) + + private fun position(t: TrackRef) = + AlbumPosition(t.albumId.ifEmpty { null }, t.discNumber, t.trackNumber) + // Abort the append loop after this many consecutive AddURIToQueue // failures; Sonos rate-limits burst adds and a wall of failures means // it has stopped accepting, not that the next one might land. - const val EXTEND_ABORT_AFTER_FAILURES = 3 - const val EXTEND_THROTTLE_MS = 50L + private const val EXTEND_ABORT_AFTER_FAILURES = 3 + private const val EXTEND_THROTTLE_MS = 50L // Verify/repair passes after a queue load. Two: one to catch the // common case (a rate-limit burst dropped a chunk), one to catch a // repair that itself got rate-limited. Beyond that the renderer is // refusing for a reason retrying won't fix, and the stall watchdog // becomes the backstop. - const val VERIFY_ROUNDS = 2 + private const val VERIFY_ROUNDS = 2 } } 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/AboutCardViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/AboutCardViewModel.kt index 2ccd36ce..c05127dc 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/AboutCardViewModel.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/AboutCardViewModel.kt @@ -41,6 +41,10 @@ data class AboutUiState( // check must decide on. Held in state rather than read inline so a test // can drive the comparison without a BuildConfig. val installedCode: Long = BuildConfig.VERSION_CODE.toLong(), + // A debug build is signed with this machine's debug key, so the server's + // release-signed APK can never install over it (family idea #5103, + // practice 9). It updates from Android Studio instead. + val selfUpdates: Boolean = !BuildConfig.DEBUG, val isChecking: Boolean = false, val installStage: InstallStage = InstallStage.IDLE, val installMessage: String? = null, @@ -67,7 +71,7 @@ class AboutCardViewModel @Inject constructor( val state: StateFlow = internal.asStateFlow() fun checkForUpdates() { - if (internal.value.isChecking) return + if (internal.value.isChecking || !internal.value.selfUpdates) return viewModelScope.launch { internal.update { it.copy(isChecking = true, installMessage = null) } val installed = internal.value.installedVersion 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..41b11fe8 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() @@ -382,6 +383,14 @@ private fun AboutCard(viewModel: AboutCardViewModel = hiltViewModel()) { @Composable private fun UpdateControls(state: AboutUiState, viewModel: AboutCardViewModel) { + if (!state.selfUpdates) { + Text( + text = "Debug build: updates install from Android Studio.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + return + } UpdateCheckLine(result = state.result) Button( onClick = viewModel::checkForUpdates, diff --git a/android/app/src/main/java/com/fabledsword/minstrel/update/data/UpdateBannerController.kt b/android/app/src/main/java/com/fabledsword/minstrel/update/data/UpdateBannerController.kt index 9d92a247..4bd9bef7 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/update/data/UpdateBannerController.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/update/data/UpdateBannerController.kt @@ -29,6 +29,9 @@ private const val POLL_INTERVAL_MS = 24 * 60 * 60 * 1000L * restart re-shows it, which is acceptable nudging for v1 (matches * Flutter). Server 404 / network errors stay silent. Constructed at * launch via the construct-the-singleton trick in `MinstrelApplication`. + * + * A debug build never polls: it is signed with a local debug key, so the + * server's release-signed APK could never install over it (#5103). */ @Singleton class UpdateBannerController @Inject constructor( @@ -45,6 +48,10 @@ class UpdateBannerController @Inject constructor( }.stateIn(scope, SharingStarted.Eagerly, null) init { + if (!BuildConfig.DEBUG) startPolling() + } + + private fun startPolling() { scope.launch { while (true) { runOnce() diff --git a/android/app/src/main/res/xml/network_security_config.xml b/android/app/src/main/res/xml/network_security_config.xml index 1fb8f930..11f3b9db 100644 --- a/android/app/src/main/res/xml/network_security_config.xml +++ b/android/app/src/main/res/xml/network_security_config.xml @@ -17,8 +17,12 @@ hostnames rather than CIDR ranges, and both sets of hosts above are unknowable until runtime. So a permissive base-config is an honest description of our situation — the gain over the manifest attribute is that the reasoning now - lives somewhere, and there is one place to tighten if a future settings screen - can distinguish a LAN server from a WAN one. + lives somewhere. + + The LAN/WAN line this file cannot draw is drawn in code instead: + api/CleartextGuard.kt refuses plain http:// to the Minstrel server whenever + the connection lands on a public address (family baseline #5105, practice + 13). LAN servers and UPnP speakers are unaffected. Worth stating because it looks worse than it is: this is NOT a tamper risk for the in-app updater. An APK altered in transit and re-signed is rejected by the diff --git a/android/app/src/test/java/com/fabledsword/minstrel/api/CleartextGuardTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/api/CleartextGuardTest.kt new file mode 100644 index 00000000..77f78910 --- /dev/null +++ b/android/app/src/test/java/com/fabledsword/minstrel/api/CleartextGuardTest.kt @@ -0,0 +1,89 @@ +package com.fabledsword.minstrel.api + +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import java.net.InetAddress +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** Plain http:// to the Minstrel server only on a private address (#5105, practice 13). */ +class CleartextGuardTest { + private lateinit var server: MockWebServer + + @BeforeEach + fun setup() { + server = MockWebServer().apply { start() } + } + + @AfterEach + fun teardown() { + server.shutdown() + } + + private fun ip(s: String) = InetAddress.getByName(s) + + @Test + fun `home network and overlay addresses may use plain http`() { + for (a in listOf( + "127.0.0.1", "10.1.2.3", "172.16.0.9", "172.31.255.1", "192.168.1.20", + "169.254.3.4", "100.64.0.1", "100.127.255.254", "::1", "fd12:3456::1", "fe80::1", + )) { + assertTrue(CleartextPolicy.allows(ip(a)), "$a should be allowed") + } + } + + @Test + fun `public addresses may not`() { + for (a in listOf( + "8.8.8.8", "172.32.0.1", "100.128.0.1", "100.63.255.255", "203.0.113.5", + "2001:db8::1", "2606:4700::1111", + )) { + assertFalse(CleartextPolicy.allows(ip(a)), "$a should be refused") + } + } + + private fun client(allows: Boolean) = OkHttpClient.Builder() + .addNetworkInterceptor(CleartextGuardInterceptor { allows }) + .build() + + private fun serverRequest() = Request.Builder() + .url(server.url("/api/auth/login")) + .tag(MinstrelServerRequest::class.java, MinstrelServerRequest) + .build() + + @Test + fun `a refused server request sends nothing`() { + server.enqueue(MockResponse().setResponseCode(200)) + assertThrows { + client(allows = false).newCall(serverRequest()).execute() + } + assertEquals(0, server.requestCount, "the request reached the server") + } + + @Test + fun `an allowed server request goes through`() { + server.enqueue(MockResponse().setResponseCode(200)) + client(allows = true).newCall(serverRequest()).execute().use { assertEquals(200, it.code) } + assertEquals(1, server.requestCount) + } + + @Test + fun `requests not bound for the Minstrel server are left alone`() { + server.enqueue(MockResponse().setResponseCode(200)) + client(allows = false).newCall(Request.Builder().url(server.url("/art.jpg")).build()) + .execute().use { assertEquals(200, it.code) } + } + + @Test + fun `the refusal has its own message`() { + val msg = ErrorCopy.fromThrowable(CleartextToPublicHostException("music.example.com")) + assertTrue(msg.contains("https://"), msg) + } +} 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/player/gain/GainMathTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/player/gain/GainMathTest.kt new file mode 100644 index 00000000..2b6bc744 --- /dev/null +++ b/android/app/src/test/java/com/fabledsword/minstrel/player/gain/GainMathTest.kt @@ -0,0 +1,111 @@ +package com.fabledsword.minstrel.player.gain + +import com.fabledsword.minstrel.settings.data.NormalizationBoost +import com.fabledsword.minstrel.settings.data.NormalizationMode +import com.fabledsword.minstrel.settings.data.NormalizationPrefs +import org.junit.jupiter.api.Test +import kotlin.math.log10 +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** Mirrors web/src/lib/player/gain.test.ts: both players must level alike. */ +class GainMathTest { + + private fun prefs( + mode: NormalizationMode = NormalizationMode.AUTO, + target: Int = -18, + boost: NormalizationBoost = NormalizationBoost.HEADROOM, + ) = NormalizationPrefs(mode = mode, targetLufs = target, boost = boost) + + private val g = ReplayGain(trackGain = -6f, trackPeak = 1f, albumGain = -4f, albumPeak = 1f) + + @Test + fun `off and unmeasured tracks play as mastered`() { + assertEquals(0f, GainMath.gainDb(prefs(mode = NormalizationMode.OFF), g, false)) + assertEquals(0f, GainMath.gainDb(prefs(), null, false)) + assertEquals(0f, GainMath.gainDb(prefs(), ReplayGain.NONE, false)) + } + + @Test + fun `mode picks the gain and auto follows album play`() { + assertEquals(-6f, GainMath.gainDb(prefs(mode = NormalizationMode.TRACK), g, true)) + assertEquals(-4f, GainMath.gainDb(prefs(mode = NormalizationMode.ALBUM), g, false)) + assertEquals(-4f, GainMath.gainDb(prefs(), g, true)) + assertEquals(-6f, GainMath.gainDb(prefs(), g, false)) + } + + @Test + fun `album gain falls back to track gain until the album is measured`() { + val partial = ReplayGain(trackGain = -6f, trackPeak = 1f, albumGain = null, albumPeak = null) + assertEquals(-6f, GainMath.gainDb(prefs(mode = NormalizationMode.ALBUM), partial, true)) + } + + @Test + fun `a louder target raises every gain by the difference`() { + assertEquals(-2f, GainMath.gainDb(prefs(mode = NormalizationMode.TRACK, target = -14), g, false)) + } + + @Test + fun `headroom stops a boost 1 dB under the true peak and the limiter lets it through`() { + val quiet = ReplayGain(trackGain = 8f, trackPeak = 0.5f, albumGain = null, albumPeak = null) + val headroom = GainMath.gainDb(prefs(mode = NormalizationMode.TRACK), quiet, false) + assertEquals(-1f - 20f * log10(0.5f), headroom, 1e-4f) + val limited = GainMath.gainDb( + prefs(mode = NormalizationMode.TRACK, boost = NormalizationBoost.LIMITER), + quiet, + false, + ) + assertEquals(8f, limited) + } + + @Test + fun `no boost passes the cap`() { + val silent = ReplayGain(trackGain = 30f, trackPeak = 0.001f, albumGain = null, albumPeak = null) + val db = GainMath.gainDb( + prefs(mode = NormalizationMode.TRACK, boost = NormalizationBoost.LIMITER), + silent, + false, + ) + assertEquals(GainMath.MAX_BOOST_DB, db) + } + + @Test + fun `cuts are never limited by the peak`() { + val hot = ReplayGain(trackGain = -9f, trackPeak = 1.4f, albumGain = null, albumPeak = null) + assertEquals(-9f, GainMath.gainDb(prefs(mode = NormalizationMode.TRACK), hot, false)) + } + + private fun pos(album: String, track: Int?, disc: Int? = 1) = AlbumPosition(album, disc, track) + + @Test + fun `in-order neighbours from the same album are album play`() { + assertTrue(GainMath.playingAsAlbum(null, pos("x", 1), pos("x", 2))) + assertTrue(GainMath.playingAsAlbum(pos("x", 1), pos("x", 2), pos("x", 3))) + assertTrue(GainMath.playingAsAlbum(pos("x", 2), pos("x", 3), null)) + } + + @Test + fun `a shuffled album, a mix and a lone track are not album play`() { + assertFalse(GainMath.playingAsAlbum(null, pos("x", 3), pos("x", 1))) + assertFalse(GainMath.playingAsAlbum(null, pos("x", 1), pos("y", 2))) + assertFalse(GainMath.playingAsAlbum(null, pos("x", 1), null)) + } + + @Test + fun `disc order counts`() { + assertTrue(GainMath.playingAsAlbum(pos("x", 12, disc = 1), pos("x", 1, disc = 2), null)) + } + + @Test + fun `a neighbour without a track number is not evidence of order`() { + assertFalse(GainMath.playingAsAlbum(pos("x", null), pos("x", 3), null)) + assertFalse(GainMath.playingAsAlbum(null, pos("x", null), pos("x", null))) + } + + @Test + fun `dbToLinear`() { + assertEquals(1f, GainMath.dbToLinear(0f)) + assertEquals(0.1f, GainMath.dbToLinear(-20f), 1e-6f) + } +} diff --git a/android/app/src/test/java/com/fabledsword/minstrel/player/gain/GainStageTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/player/gain/GainStageTest.kt new file mode 100644 index 00000000..6995c2b1 --- /dev/null +++ b/android/app/src/test/java/com/fabledsword/minstrel/player/gain/GainStageTest.kt @@ -0,0 +1,73 @@ +package com.fabledsword.minstrel.player.gain + +import org.junit.jupiter.api.Test +import java.nio.ByteBuffer +import java.nio.ByteOrder +import kotlin.math.abs +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class GainStageTest { + + private fun floats(vararg v: Float): ByteBuffer = + ByteBuffer.allocateDirect(v.size * 4).order(ByteOrder.nativeOrder()).apply { + v.forEach { putFloat(it) } + flip() + } + + private fun shorts(vararg v: Short): ByteBuffer = + ByteBuffer.allocateDirect(v.size * 2).order(ByteOrder.nativeOrder()).apply { + v.forEach { putShort(it) } + flip() + } + + private fun runFloat(stage: GainStage, input: ByteBuffer, target: Float, limiting: Boolean): FloatArray { + val out = ByteBuffer.allocateDirect(input.remaining()).order(ByteOrder.nativeOrder()) + stage.process(input, out, channels = 2, isFloat = true, target = target, limiting = limiting) + out.flip() + return FloatArray(out.remaining() / 4) { out.float } + } + + @Test + fun `a stream that starts at its gain is scaled from the first sample`() { + val stage = GainStage().apply { reset(sampleRate = 44_100, channels = 2, startGain = 0.5f) } + val out = runFloat(stage, floats(0.8f, -0.4f), target = 0.5f, limiting = false) + assertEquals(0.4f, out[0], 1e-6f) + assertEquals(-0.2f, out[1], 1e-6f) + } + + @Test + fun `a changed target ramps rather than steps`() { + val stage = GainStage().apply { reset(sampleRate = 44_100, channels = 2, startGain = 1f) } + val n = 44_100 // one second of stereo frames + val input = floats(*FloatArray(n * 2) { 0.5f }) + val out = runFloat(stage, input, target = 0.5f, limiting = false) + assertTrue(out[0] > 0.49f, "first frame barely moved: ${out[0]}") + assertEquals(0.25f, out.last(), 1e-3f) + } + + @Test + fun `the limiter holds a boosted peak at -1 dBFS`() { + val stage = GainStage().apply { reset(sampleRate = 44_100, channels = 2, startGain = 4f) } + val out = runFloat(stage, floats(0.9f, -0.9f, 0.1f, 0.1f), target = 4f, limiting = true) + out.forEach { assertTrue(abs(it) <= GainStage.LIMIT_CEILING + 1e-6f, "sample $it over the ceiling") } + } + + @Test + fun `without the limiter a boost is clamped to full scale, 16-bit included`() { + val stage = GainStage().apply { reset(sampleRate = 44_100, channels = 2, startGain = 4f) } + val input = shorts(20_000, -20_000) + val out = ByteBuffer.allocateDirect(4).order(ByteOrder.nativeOrder()) + stage.process(input, out, channels = 2, isFloat = false, target = 4f, limiting = false) + out.flip() + assertEquals(Short.MAX_VALUE, out.short) + assertEquals((-Short.MAX_VALUE).toShort(), out.short) + } + + @Test + fun `unity is a pass-through`() { + val stage = GainStage().apply { reset(sampleRate = 44_100, channels = 2, startGain = 1f) } + assertTrue(stage.isUnity(target = 1f, limiting = false)) + assertTrue(!stage.isUnity(target = 1f, limiting = true)) + } +} diff --git a/android/app/src/test/java/com/fabledsword/minstrel/player/gain/ReplayGainStoreTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/player/gain/ReplayGainStoreTest.kt new file mode 100644 index 00000000..5fccb71c --- /dev/null +++ b/android/app/src/test/java/com/fabledsword/minstrel/player/gain/ReplayGainStoreTest.kt @@ -0,0 +1,74 @@ +package com.fabledsword.minstrel.player.gain + +import com.fabledsword.minstrel.api.endpoints.ReplayGainApi +import com.fabledsword.minstrel.cache.db.dao.CachedReplayGain +import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao +import com.fabledsword.minstrel.connectivity.ServerHealth +import com.fabledsword.minstrel.models.wire.ReplayGainResponseWire +import com.fabledsword.minstrel.models.wire.ReplayGainWire +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import java.io.IOException +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** The fallback ladder: the library cache, then the server, then nothing. */ +class ReplayGainStoreTest { + private val dao: CachedTrackDao = mockk() + private val api: ReplayGainApi = mockk() + private var health = ServerHealth.Healthy + + private fun store() = ReplayGainStore( + scope = TestScope(), + trackDao = dao, + api = api, + serverHealth = { health }, + clock = { 0L }, + ) + + @Test + fun `cached gains are used and only the rest are asked of the server`() = runTest { + coEvery { dao.replayGains(listOf("a", "b", "c")) } returns listOf( + CachedReplayGain("a", trackGain = -6f, trackPeak = 1f, albumGain = -4f, albumPeak = 1f), + // Cached but unmeasured at the last sync: the server may know more. + CachedReplayGain("b", trackGain = null, trackPeak = null, albumGain = null, albumPeak = null), + ) + coEvery { api.getReplayGain("b,c") } returns ReplayGainResponseWire( + items = mapOf("b" to ReplayGainWire(trackGain = -3f, trackPeak = 0.9f)), + ) + val s = store() + + s.load(listOf("a", "b", "c")) + + assertEquals(-4f, s.get("a")?.albumGain) + assertEquals(-3f, s.get("b")?.trackGain) + assertNull(s.get("c"), "a track the server has not measured stays unknown") + } + + @Test + fun `offline, the cache is all there is`() = runTest { + health = ServerHealth.Offline + coEvery { dao.replayGains(any()) } returns emptyList() + val s = store() + + s.load(listOf("a")) + + assertNull(s.get("a")) + coVerify(exactly = 0) { api.getReplayGain(any()) } + } + + @Test + fun `a failed lookup leaves the track unknown, to be asked again`() = runTest { + coEvery { dao.replayGains(any()) } returns emptyList() + coEvery { api.getReplayGain(any()) } throws IOException("reset") + val s = store() + + s.load(listOf("a")) + + assertNull(s.get("a")) + } +} diff --git a/android/app/src/test/java/com/fabledsword/minstrel/player/output/SonosAlbumPlayTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/player/output/SonosAlbumPlayTest.kt new file mode 100644 index 00000000..d5e28f69 --- /dev/null +++ b/android/app/src/test/java/com/fabledsword/minstrel/player/output/SonosAlbumPlayTest.kt @@ -0,0 +1,43 @@ +package com.fabledsword.minstrel.player.output + +import com.fabledsword.minstrel.models.TrackRef +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals + +/** + * The Sonos queue plays straight through, so a track's album play is judged + * from its neighbours in the list sent (M464 #5002). + */ +class SonosAlbumPlayTest { + + private fun t(album: String, n: Int?, disc: Int? = null) = + TrackRef(id = "$album-$disc-$n", title = "", albumId = album, artistId = "", trackNumber = n, discNumber = disc) + + private fun asAlbum(queue: List) = queue.indices.map { SonosQueueLoader.playingAsAlbum(queue, it) } + + @Test + fun `an album in order plays as an album throughout`() { + assertEquals(listOf(true, true, true), asAlbum(listOf(t("a", 1), t("a", 2), t("a", 3)))) + } + + @Test + fun `a mix of albums plays as singles`() { + assertEquals(listOf(false, false, false), asAlbum(listOf(t("a", 1), t("b", 1), t("c", 4)))) + } + + @Test + fun `album order runs across discs`() { + assertEquals(listOf(true, true), asAlbum(listOf(t("a", 12, disc = 1), t("a", 1, disc = 2)))) + } + + @Test + fun `shuffled tracks of one album are not album play`() { + assertEquals(listOf(false, false), asAlbum(listOf(t("a", 3), t("a", 1)))) + } + + @Test + fun `unnumbered tracks and missing albums are never album play`() { + assertEquals(listOf(false, false), asAlbum(listOf(t("a", null), t("a", null)))) + assertEquals(listOf(false, false), asAlbum(listOf(t("", 1), t("", 2)))) + } +} 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/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 176c8c31..a551209c 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -21,7 +21,7 @@ kotlinx-serialization = "1.7.3" kotlinx-coroutines = "1.9.0" kotlinx-datetime = "0.6.1" kotlinx-serialization-converter = "1.0.0" -media3 = "1.10.1" +media3 = "1.11.0" mediarouter = "1.7.0" coil = "3.0.0-rc02" palette = "1.0.0" diff --git a/go.mod b/go.mod index b78e9bb6..2c7393f7 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( github.com/jackc/pgx/v5 v5.9.2 github.com/stretchr/testify v1.11.1 golang.org/x/crypto v0.51.0 + golang.org/x/sync v0.21.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -25,7 +26,6 @@ require ( github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/robfig/cron/v3 v3.0.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect - golang.org/x/sync v0.21.0 // indirect golang.org/x/sys v0.44.0 // indirect golang.org/x/text v0.39.0 // indirect ) diff --git a/internal/api/admin_loudness.go b/internal/api/admin_loudness.go index 13cf850d..3df88ffc 100644 --- a/internal/api/admin_loudness.go +++ b/internal/api/admin_loudness.go @@ -46,10 +46,15 @@ func (h *handlers) handleGetLoudnessCoverage(w http.ResponseWriter, r *http.Requ type loudnessSettingsBody struct { Enabled bool `json:"enabled"` BackfillConcurrency int32 `json:"backfill_concurrency"` + LeveledCacheMB int32 `json:"leveled_cache_mb"` } func loudnessSettingsBodyOf(s library.LoudnessSettings) loudnessSettingsBody { - return loudnessSettingsBody{Enabled: s.Enabled, BackfillConcurrency: s.BackfillConcurrency} + return loudnessSettingsBody{ + Enabled: s.Enabled, + BackfillConcurrency: s.BackfillConcurrency, + LeveledCacheMB: s.LeveledCacheMB, + } } // handleGetLoudnessSettings implements GET /api/admin/library/loudness-settings. @@ -69,6 +74,7 @@ func (h *handlers) handleUpdateLoudnessSettings(w http.ResponseWriter, r *http.R saved, err := h.loudnessSettings.Set(r.Context(), library.LoudnessSettings{ Enabled: req.Enabled, BackfillConcurrency: req.BackfillConcurrency, + LeveledCacheMB: req.LeveledCacheMB, }) if err != nil { if errors.Is(err, library.ErrLoudnessSettingOutOfRange) { diff --git a/internal/api/api.go b/internal/api/api.go index a495c2fa..55318155 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -7,6 +7,7 @@ package api import ( "log/slog" "math/rand" + "path/filepath" "time" "github.com/go-chi/chi/v5" @@ -66,6 +67,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev reacqSettings: reacqSettings, fingerprintSettings: fpSettings, loudnessSettings: loudSettings, + leveled: newLeveledRenderer(dataDir, loudSettings, logger), librarySize: recommendation.NewLibrarySize(nil), loginGuard: auth.NewLoginGuard(), setupToken: setupToken, @@ -96,6 +98,9 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev // audio format from the path. The {ext} param is consumed by chi // and ignored by the handler (which keys off {id}). See task #610. api.With(auth.OptionalUser(pool, logger)).Get("/tracks/{id}/stream.{ext}", h.handleGetStream) + // The leveled stream for Sonos/UPnP (M464 #5001): session or a + // leveled token, like the plain stream. + api.With(auth.OptionalUser(pool, logger)).Get("/tracks/{id}/leveled.flac", h.handleGetLeveledStream) api.Group(func(authed chi.Router) { authed.Use(auth.RequireUser(pool, netSettings.Hops)) @@ -109,6 +114,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) @@ -341,6 +348,10 @@ type handlers struct { // loudnessSettings is the loudness analysis policy (M464 #4995), the same // instance the loudness backfill reads. Nil serves the defaults. loudnessSettings *library.LoudnessSettingsService + // leveled renders the leveled streams handed to Sonos/UPnP speakers + // (M464 #5001). Nil when its cache directory cannot be made: a level + // request then gets the plain stream. + leveled *library.LeveledRenderer // setupToken must accompany the first registration while no users exist // (see auth.SetupToken). requireSetupToken is set by Mount, the only // production constructor; tests that build handlers directly leave it @@ -368,3 +379,14 @@ type handlers struct { // anything a client mints), which is the desired slice-1 default. streamSecret []byte } + +// newLeveledRenderer makes the leveled-stream renderer, caching under the +// data directory. A failure is logged and leaves leveling off for speakers. +func newLeveledRenderer(dataDir string, settings *library.LoudnessSettingsService, logger *slog.Logger) *library.LeveledRenderer { + r, err := library.NewLeveledRenderer(filepath.Join(dataDir, "leveled-cache"), settings, logger) + if err != nil { + logger.Error("api: leveled streams unavailable", "err", err) + return nil + } + return r +} diff --git a/internal/api/cast_token.go b/internal/api/cast_token.go index 0e18e869..8930f2c5 100644 --- a/internal/api/cast_token.go +++ b/internal/api/cast_token.go @@ -8,6 +8,7 @@ import ( "git.fabledsword.com/bvandeusen/minstrel/internal/apierror" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + "git.fabledsword.com/bvandeusen/minstrel/internal/library" ) const ( @@ -19,6 +20,17 @@ const ( type castTokenRequest struct { TrackID string `json:"trackId"` ExpSeconds int `json:"expSeconds,omitempty"` + // Level asks for the leveled stream (M464 #5001): the track rendered at + // the user's loudness gain. AsAlbum says the track is playing as part of + // its album in order, which picks album gain in auto mode; only the + // client holding the queue knows it. + Level bool `json:"level,omitempty"` + AsAlbum bool `json:"asAlbum,omitempty"` + // Prerender says the speaker will fetch this track soon: the current + // track at a queue load, or the next one as it starts. Only those are + // rendered ahead; a queue load mints every track and renders none of + // the rest until the speaker asks. + Prerender bool `json:"prerender,omitempty"` } type castTokenResponse struct { @@ -31,6 +43,10 @@ type castTokenResponse struct { // `` and `` without a follow-up round trip. MIME string `json:"mime"` Title string `json:"title"` + // Leveled is true when URL is the leveled stream. A level request still + // gets the plain stream when there is nothing to change: leveling off, + // the track not yet measured, or a gain of 0. + Leveled bool `json:"leveled"` } // mimeForFormat returns the audio MIME type for a cast (Sonos/UPnP) URL. @@ -89,7 +105,8 @@ func extForFormat(format string) string { // Part of the output-picker UPnP slice. See // docs/superpowers/specs/2026-06-03-android-output-picker-upnp-design.md. func (h *handlers) handleCastStreamToken(w http.ResponseWriter, r *http.Request) { - if _, ok := requireUser(w, r); !ok { + user, ok := requireUser(w, r) + if !ok { return } var req castTokenRequest @@ -112,6 +129,27 @@ func (h *handlers) handleCastStreamToken(w http.ResponseWriter, r *http.Request) expSec := clampExpSeconds(req.ExpSeconds) exp := time.Now().Add(time.Duration(expSec) * time.Second).Unix() token := SignStreamToken(h.streamSecret, req.TrackID, exp) + path := streamURLWithExt(trackUUID, extForFormat(track.FileFormat)) + + "?token=" + token + "&exp=" + strconv.FormatInt(exp, 10) + mime := mimeForFormat(track.FileFormat) + leveled := false + if req.Level && h.leveled != nil { + g, err := h.leveledGainFor(r.Context(), user.ID, trackUUID, req.AsAlbum) + if err != nil { + // The plain stream still plays; only the leveling is lost. + h.logger.Warn("cast token: leveled gain lookup failed", "track", req.TrackID, "err", err) + } else if !g.Unity() { + token = SignLeveledStreamToken(h.streamSecret, req.TrackID, exp, g) + path = leveledStreamPath(trackUUID) + leveledQuery(g, token, exp) + mime = "audio/flac" + leveled = true + if req.Prerender { + h.leveled.Prerender(library.LeveledSource{ + TrackID: req.TrackID, Path: track.FilePath, DurationMs: track.DurationMs, + }, g) + } + } + } // Behind a TLS-terminating reverse proxy, r.TLS is nil even though // the public-facing URL is https://. UPnP devices (Sonos especially) @@ -131,18 +169,16 @@ func (h *handlers) handleCastStreamToken(w http.ResponseWriter, r *http.Request) if h := r.Header.Get("X-Forwarded-Host"); h != "" { host = h } - // Include the file extension in the path so Sonos's URL probe sees a + // The path carries a file extension so Sonos's URL probe sees a // recognizable audio file. Without it, Sonos reports TrackDuration=0 // and seeks past 0s land "after the end" -> early track-skip. - url := scheme + "://" + host + streamURLWithExt(trackUUID, extForFormat(track.FileFormat)) + - "?token=" + token + "&exp=" + strconv.FormatInt(exp, 10) - writeJSON(w, http.StatusOK, castTokenResponse{ - Token: token, - Exp: exp, - URL: url, - MIME: mimeForFormat(track.FileFormat), - Title: track.Title, + Token: token, + Exp: exp, + URL: scheme + "://" + host + path, + MIME: mime, + Title: track.Title, + Leveled: leveled, }) } diff --git a/internal/api/leveled_stream.go b/internal/api/leveled_stream.go new file mode 100644 index 00000000..6c01f708 --- /dev/null +++ b/internal/api/leveled_stream.go @@ -0,0 +1,168 @@ +package api + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "net/http" + "os" + "strconv" + "time" + + "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5/pgtype" + + "git.fabledsword.com/bvandeusen/minstrel/internal/apierror" + "git.fabledsword.com/bvandeusen/minstrel/internal/auth" + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + "git.fabledsword.com/bvandeusen/minstrel/internal/library" +) + +// The leveled stream (M464 #5001): a track rendered with the user's loudness +// gain applied, for the Sonos and UPnP speakers that fetch their own audio. +// See internal/library/leveled.go for how it is rendered. + +// SignLeveledStreamToken signs a leveled stream URL. The gain is part of what +// is signed, so a speaker's URL cannot be edited into a different render. +// The message cannot collide with SignStreamToken's "|": a plain +// token does not open a leveled stream, nor the reverse. +func SignLeveledStreamToken(secret []byte, trackID string, exp int64, g library.LeveledGain) string { + mac := hmac.New(sha256.New, secret) + lim := 0 + if g.Limiter { + lim = 1 + } + _, _ = fmt.Fprintf(mac, "%s|%d|leveled|%d|%d", trackID, exp, g.CentiDB, lim) + return hex.EncodeToString(mac.Sum(nil)) +} + +// VerifyLeveledStreamToken checks a token from SignLeveledStreamToken, and +// that it has not expired. +func VerifyLeveledStreamToken(secret []byte, trackID string, exp int64, g library.LeveledGain, token string) bool { + if time.Now().Unix() > exp { + return false + } + return hmac.Equal([]byte(SignLeveledStreamToken(secret, trackID, exp, g)), []byte(token)) +} + +// leveledStreamPath is the leveled stream's path. It ends in .flac because +// Sonos reads the format from the URL's extension (task #610). +func leveledStreamPath(trackID pgtype.UUID) string { + return "/api/tracks/" + uuidToString(trackID) + "/leveled.flac" +} + +// leveledQuery is the query string a leveled URL carries. +func leveledQuery(g library.LeveledGain, token string, exp int64) string { + lim := "0" + if g.Limiter { + lim = "1" + } + return "?g=" + strconv.Itoa(g.CentiDB) + "&lim=" + lim + + "&token=" + token + "&exp=" + strconv.FormatInt(exp, 10) +} + +// leveledGainFor is the render request for one track under the user's +// preference. Unity when leveling is off or the track is unmeasured, which +// the caller answers with the plain stream. +func (h *handlers) leveledGainFor(ctx context.Context, userID, trackID pgtype.UUID, asAlbum bool) (library.LeveledGain, error) { + q := dbq.New(h.pool) + prefs, err := library.LoadNormalizationPrefs(ctx, q, userID) + if err != nil { + return library.LeveledGain{}, err + } + gains, err := library.ReplayGainForTracks(ctx, q, []pgtype.UUID{trackID}) + if err != nil { + return library.LeveledGain{}, err + } + return library.NewLeveledGain(library.LeveledGainDB(prefs, gains[trackID], asAlbum), prefs.Boost), nil +} + +// parseLeveledGain reads ?g= and ?lim= from a leveled URL. +func parseLeveledGain(r *http.Request) (library.LeveledGain, bool) { + c, err := strconv.Atoi(r.URL.Query().Get("g")) + if err != nil { + return library.LeveledGain{}, false + } + lim := r.URL.Query().Get("lim") + if lim != "0" && lim != "1" { + return library.LeveledGain{}, false + } + g := library.LeveledGain{CentiDB: c, Limiter: lim == "1"} + return g, g.Valid() +} + +// handleGetLeveledStream implements GET /api/tracks/{id}/leveled.flac. It +// accepts a session, like the plain stream, or a leveled token: the gain in +// the query must be the one the token was signed for. +func (h *handlers) handleGetLeveledStream(w http.ResponseWriter, r *http.Request) { + rawID := chi.URLParam(r, "id") + g, ok := parseLeveledGain(r) + if !h.leveledAuthOk(r, rawID, g, ok) { + writeErr(w, apierror.ErrUnauthorized) + return + } + if !ok { + writeErr(w, apierror.BadRequest("invalid_gain", "g and lim must describe a valid gain")) + return + } + if h.leveled == nil { + writeErr(w, &apierror.Error{Status: http.StatusServiceUnavailable, Code: "leveling_unavailable", + Message: "leveled streams are not available on this server"}) + return + } + track, apiErr := resolveByID(r, "id", dbq.New(h.pool).GetTrackByID, "track") + if apiErr != nil { + writeErr(w, apiErr) + return + } + path, err := h.leveled.Path(r.Context(), library.LeveledSource{ + TrackID: rawID, Path: track.FilePath, DurationMs: track.DurationMs, + }, g) + switch { + case errors.Is(err, library.ErrLeveledSourceMissing): + writeErr(w, &apierror.Error{Status: http.StatusNotFound, Code: "not_found", Message: "track file not found"}) + return + case r.Context().Err() != nil: + return // the speaker hung up; the render carries on for its retry + case err != nil: + writeErrWithLog(w, h.logger, "leveled stream: render failed", apierror.InternalMsg("render failed", err)) + return + } + f, err := os.Open(path) + if err != nil { + // Evicted between render and open: rare, and the speaker retries. + writeErrWithLog(w, h.logger, "leveled stream: open render", apierror.InternalMsg("server error", err)) + return + } + defer func() { _ = f.Close() }() + info, err := f.Stat() + if err != nil { + writeErrWithLog(w, h.logger, "leveled stream: stat render", apierror.InternalMsg("server error", err)) + return + } + w.Header().Set("Content-Type", "audio/flac") + w.Header().Set("Accept-Ranges", "bytes") + w.Header().Set("Cache-Control", "private, max-age=86400") + http.ServeContent(w, r, "leveled.flac", info.ModTime(), f) +} + +// leveledAuthOk mirrors streamAuthOk: a session, or a token signed over this +// track and this gain. A malformed gain fails the token path, since there is +// nothing it could have been signed over. +func (h *handlers) leveledAuthOk(r *http.Request, trackID string, g library.LeveledGain, gainOK bool) bool { + if _, ok := auth.UserFromContext(r.Context()); ok { + return true + } + if !gainOK { + return false + } + tok := r.URL.Query().Get("token") + exp, err := strconv.ParseInt(r.URL.Query().Get("exp"), 10, 64) + if tok == "" || err != nil { + return false + } + return VerifyLeveledStreamToken(h.streamSecret, trackID, exp, g, tok) +} diff --git a/internal/api/leveled_stream_test.go b/internal/api/leveled_stream_test.go new file mode 100644 index 00000000..9bab2feb --- /dev/null +++ b/internal/api/leveled_stream_test.go @@ -0,0 +1,161 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + "time" + + "github.com/go-chi/chi/v5" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + "git.fabledsword.com/bvandeusen/minstrel/internal/library" +) + +func TestLeveledStreamToken(t *testing.T) { + secret := []byte("leveled-secret") + exp := time.Now().Unix() + 3600 + g := library.LeveledGain{CentiDB: -550} + tok := SignLeveledStreamToken(secret, "track-1", exp, g) + + if !VerifyLeveledStreamToken(secret, "track-1", exp, g, tok) { + t.Fatal("round trip failed") + } + // The gain is part of what is signed: a speaker URL edited to another + // gain, or to switch the limiter on, no longer verifies. + if VerifyLeveledStreamToken(secret, "track-1", exp, library.LeveledGain{CentiDB: 1200}, tok) { + t.Error("token verified for a different gain") + } + if VerifyLeveledStreamToken(secret, "track-1", exp, library.LeveledGain{CentiDB: -550, Limiter: true}, tok) { + t.Error("token verified with the limiter switched on") + } + if VerifyLeveledStreamToken(secret, "track-2", exp, g, tok) { + t.Error("token verified for another track") + } + if VerifyLeveledStreamToken(secret, "track-1", time.Now().Unix()-1, g, SignLeveledStreamToken(secret, "track-1", time.Now().Unix()-1, g)) { + t.Error("expired token verified") + } + // Plain and leveled tokens are not interchangeable. + plain := SignStreamToken(secret, "track-1", exp) + if VerifyLeveledStreamToken(secret, "track-1", exp, library.LeveledGain{}, plain) { + t.Error("a plain stream token opened a leveled stream") + } + if VerifyStreamToken(secret, "track-1", exp, tok) { + t.Error("a leveled token opened the plain stream") + } +} + +func TestParseLeveledGain(t *testing.T) { + for _, c := range []struct { + query string + want library.LeveledGain + ok bool + }{ + {"g=-550&lim=0", library.LeveledGain{CentiDB: -550}, true}, + {"g=320&lim=1", library.LeveledGain{CentiDB: 320, Limiter: true}, true}, + {"g=1201&lim=0", library.LeveledGain{}, false}, + {"g=abc&lim=0", library.LeveledGain{}, false}, + {"g=100&lim=yes", library.LeveledGain{}, false}, + {"lim=0", library.LeveledGain{}, false}, + } { + got, ok := parseLeveledGain(httptest.NewRequest(http.MethodGet, "/x?"+c.query, nil)) + if ok != c.ok || (ok && got != c.want) { + t.Errorf("%s: got %+v ok=%v, want %+v ok=%v", c.query, got, ok, c.want, c.ok) + } + } +} + +func TestCastStreamToken_Leveled(t *testing.T) { + h, pool := testHandlers(t) + h.streamSecret = []byte("leveled-cast-secret") + r, err := library.NewLeveledRenderer(t.TempDir(), nil, slog.New(slog.NewTextHandler(io.Discard, nil))) + if err != nil { + t.Fatal(err) + } + h.leveled = r + user := seedUser(t, pool, "lev", "pw", false) + track, _ := seedTrackForRemoveTest(t, h, "lev", "", "") + trackID := uuidToString(track.ID) + lufs, peak := float32(-12.5), float32(-1) + if err := dbq.New(pool).UpsertTrackLoudness(context.Background(), dbq.UpsertTrackLoudnessParams{ + TrackID: track.ID, IntegratedLufs: &lufs, TruePeakDbtp: &peak, AnalysisVersion: 1, + }); err != nil { + t.Fatalf("seed loudness: %v", err) + } + mint := func(req castTokenRequest) castTokenResponse { + t.Helper() + body, _ := json.Marshal(req) + w := httptest.NewRecorder() + h.handleCastStreamToken(w, withUser(httptest.NewRequest(http.MethodPost, "/api/cast/stream-token", bytes.NewReader(body)), user)) + if w.Code != http.StatusOK { + t.Fatalf("status %d: %s", w.Code, w.Body.String()) + } + var resp castTokenResponse + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatal(err) + } + return resp + } + + // Default preference (auto, -18): the track measures -12.5 LUFS, so it is + // cut by 5.5 dB, and the URL and its token say exactly that. + resp := mint(castTokenRequest{TrackID: trackID, Level: true}) + if !resp.Leveled || resp.MIME != "audio/flac" || + !strings.Contains(resp.URL, "/api/tracks/"+trackID+"/leveled.flac?g=-550&lim=0&token=") { + t.Fatalf("leveled mint = %+v", resp) + } + if !VerifyLeveledStreamToken(h.streamSecret, trackID, resp.Exp, library.LeveledGain{CentiDB: -550}, resp.Token) { + t.Fatal("leveled token does not verify for the gain in the URL") + } + + // Not asked for: the plain stream, as before. + if resp := mint(castTokenRequest{TrackID: trackID}); resp.Leveled || strings.Contains(resp.URL, "leveled") { + t.Fatalf("unleveled mint = %+v", resp) + } + + // Leveling switched off: nothing to render, so the plain stream. + if _, err := library.SaveNormalizationPrefs(context.Background(), dbq.New(pool), user.ID, + library.NormalizationPrefs{Mode: "off", TargetLUFS: -18, Boost: "headroom"}); err != nil { + t.Fatal(err) + } + if resp := mint(castTokenRequest{TrackID: trackID, Level: true}); resp.Leveled { + t.Fatalf("mint with leveling off = %+v, want the plain stream", resp) + } +} + +func TestGetLeveledStream_RefusesUnsignedGains(t *testing.T) { + h, _ := testHandlers(t) + h.streamSecret = []byte("leveled-get-secret") + router := chi.NewRouter() + router.Get("/api/tracks/{id}/leveled.flac", h.handleGetLeveledStream) + id := nonExistentTrackUUID + exp := time.Now().Unix() + 3600 + tok := SignLeveledStreamToken(h.streamSecret, id, exp, library.LeveledGain{CentiDB: -300}) + get := func(query string) int { + w := httptest.NewRecorder() + router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/tracks/"+id+"/leveled.flac?"+query, nil)) + return w.Code + } + e := strconv.FormatInt(exp, 10) + for name, q := range map[string]string{ + "no token": "g=-300&lim=0", + "edited gain": "g=1200&lim=0&token=" + tok + "&exp=" + e, + "limiter on": "g=-300&lim=1&token=" + tok + "&exp=" + e, + "invalid gain": "g=99999&lim=0&token=" + tok + "&exp=" + e, + } { + if code := get(q); code != http.StatusUnauthorized { + t.Errorf("%s: status %d, want 401", name, code) + } + } + // The signed gain gets past auth to the track lookup. + if code := get("g=-300&lim=0&token=" + tok + "&exp=" + e); code == http.StatusUnauthorized { + t.Errorf("the signed gain was refused") + } +} 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/albums.sql.go b/internal/db/dbq/albums.sql.go index 98b07b4a..04ce65e6 100644 --- a/internal/db/dbq/albums.sql.go +++ b/internal/db/dbq/albums.sql.go @@ -552,6 +552,7 @@ SELECT a.id AS album_id, SELECT file_path FROM tracks WHERE album_id = a.id + AND missing_since IS NULL ORDER BY disc_number NULLS LAST, track_number NULLS LAST, id LIMIT 1 ) t ON true @@ -569,7 +570,9 @@ type ListAlbumsMissingMbidWithTrackRow struct { // One-shot MBID backfill: returns each album where mbid IS NULL alongside // one of its tracks' file_path so the worker can re-read tags. LIMIT -// supplied by caller for batching/progress purposes. +// supplied by caller for batching/progress purposes. The track read is +// one still on disk; an album with none left is skipped until a scan +// finds its files again (#5139). func (q *Queries) ListAlbumsMissingMbidWithTrack(ctx context.Context, limit int32) ([]ListAlbumsMissingMbidWithTrackRow, error) { rows, err := q.db.Query(ctx, listAlbumsMissingMbidWithTrack, limit) if err != nil { diff --git a/internal/db/dbq/loudness.sql.go b/internal/db/dbq/loudness.sql.go index 59d3c143..c7d1df1d 100644 --- a/internal/db/dbq/loudness.sql.go +++ b/internal/db/dbq/loudness.sql.go @@ -127,7 +127,7 @@ func (q *Queries) GetLoudnessCoverage(ctx context.Context, currentVersion int16) } const getLoudnessSettings = `-- name: GetLoudnessSettings :one -SELECT id, enabled, backfill_concurrency, updated_at FROM loudness_settings WHERE id = true +SELECT id, enabled, backfill_concurrency, updated_at, leveled_cache_mb FROM loudness_settings WHERE id = true ` func (q *Queries) GetLoudnessSettings(ctx context.Context) (LoudnessSetting, error) { @@ -138,6 +138,7 @@ func (q *Queries) GetLoudnessSettings(ctx context.Context) (LoudnessSetting, err &i.Enabled, &i.BackfillConcurrency, &i.UpdatedAt, + &i.LeveledCacheMb, ) return i, err } @@ -388,26 +389,29 @@ const updateLoudnessSettings = `-- name: UpdateLoudnessSettings :one UPDATE loudness_settings SET enabled = $1, backfill_concurrency = $2, + leveled_cache_mb = $3, updated_at = now() WHERE id = true -RETURNING id, enabled, backfill_concurrency, updated_at +RETURNING id, enabled, backfill_concurrency, updated_at, leveled_cache_mb ` type UpdateLoudnessSettingsParams struct { Enabled bool BackfillConcurrency int32 + LeveledCacheMb int32 } -// Whole-row write from the admin card; migration 0065's CHECK is the backstop +// Whole-row write from the admin card; the migrations' CHECKs are the backstop // behind the service's own validation. func (q *Queries) UpdateLoudnessSettings(ctx context.Context, arg UpdateLoudnessSettingsParams) (LoudnessSetting, error) { - row := q.db.QueryRow(ctx, updateLoudnessSettings, arg.Enabled, arg.BackfillConcurrency) + row := q.db.QueryRow(ctx, updateLoudnessSettings, arg.Enabled, arg.BackfillConcurrency, arg.LeveledCacheMb) var i LoudnessSetting err := row.Scan( &i.ID, &i.Enabled, &i.BackfillConcurrency, &i.UpdatedAt, + &i.LeveledCacheMb, ) return i, err } diff --git a/internal/db/dbq/models.go b/internal/db/dbq/models.go index fd55ee9c..e62b9121 100644 --- a/internal/db/dbq/models.go +++ b/internal/db/dbq/models.go @@ -432,6 +432,7 @@ type LoudnessSetting struct { Enabled bool BackfillConcurrency int32 UpdatedAt pgtype.Timestamptz + LeveledCacheMb int32 } type MissingReacquisition struct { @@ -784,6 +785,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/dbq/tracks.sql.go b/internal/db/dbq/tracks.sql.go index 4cfe2964..638a5687 100644 --- a/internal/db/dbq/tracks.sql.go +++ b/internal/db/dbq/tracks.sql.go @@ -668,6 +668,7 @@ const listTracksMissingMbidWithPath = `-- name: ListTracksMissingMbidWithPath :m SELECT id, file_path FROM tracks WHERE mbid IS NULL + AND missing_since IS NULL ORDER BY id LIMIT $1 ` @@ -679,6 +680,8 @@ type ListTracksMissingMbidWithPathRow struct { // Track recording-MBID backfill: tracks with NULL mbid that still have // a file to re-read. $1 caps the batch (mirrors the album backfill). +// Missing tracks are skipped: there is no file, and opening it anyway +// logged a warning per track on every scan (#5139). func (q *Queries) ListTracksMissingMbidWithPath(ctx context.Context, limit int32) ([]ListTracksMissingMbidWithPathRow, error) { rows, err := q.db.Query(ctx, listTracksMissingMbidWithPath, limit) if err != nil { 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/migrations/0068_leveled_stream_cache.down.sql b/internal/db/migrations/0068_leveled_stream_cache.down.sql new file mode 100644 index 00000000..59ab4d17 --- /dev/null +++ b/internal/db/migrations/0068_leveled_stream_cache.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE loudness_settings + DROP CONSTRAINT loudness_settings_leveled_cache_range, + DROP COLUMN leveled_cache_mb; diff --git a/internal/db/migrations/0068_leveled_stream_cache.up.sql b/internal/db/migrations/0068_leveled_stream_cache.up.sql new file mode 100644 index 00000000..941bd128 --- /dev/null +++ b/internal/db/migrations/0068_leveled_stream_cache.up.sql @@ -0,0 +1,12 @@ +-- 0068_leveled_stream_cache.up.sql — the size cap of the leveled-stream render +-- cache (Scribe milestone #464, #5001). +-- +-- Sonos and other UPnP renderers fetch a track's URL themselves, so the phone +-- cannot level what they play. The server renders a gain-applied FLAC for them +-- instead and keeps it on disk, oldest-used first out once the cache is over +-- this size. An operator setting (rule 25), on the loudness settings row it +-- belongs with. +ALTER TABLE loudness_settings + ADD COLUMN leveled_cache_mb integer NOT NULL DEFAULT 2048, + ADD CONSTRAINT loudness_settings_leveled_cache_range + CHECK (leveled_cache_mb >= 256 AND leveled_cache_mb <= 65536); diff --git a/internal/db/queries/albums.sql b/internal/db/queries/albums.sql index 6f87dfc8..c9b88fa9 100644 --- a/internal/db/queries/albums.sql +++ b/internal/db/queries/albums.sql @@ -145,7 +145,9 @@ UPDATE albums -- name: ListAlbumsMissingMbidWithTrack :many -- One-shot MBID backfill: returns each album where mbid IS NULL alongside -- one of its tracks' file_path so the worker can re-read tags. LIMIT --- supplied by caller for batching/progress purposes. +-- supplied by caller for batching/progress purposes. The track read is +-- one still on disk; an album with none left is skipped until a scan +-- finds its files again (#5139). SELECT a.id AS album_id, a.artist_id AS artist_id, a.title AS title, @@ -155,6 +157,7 @@ SELECT a.id AS album_id, SELECT file_path FROM tracks WHERE album_id = a.id + AND missing_since IS NULL ORDER BY disc_number NULLS LAST, track_number NULLS LAST, id LIMIT 1 ) t ON true diff --git a/internal/db/queries/loudness.sql b/internal/db/queries/loudness.sql index af33fb1b..90a92c2d 100644 --- a/internal/db/queries/loudness.sql +++ b/internal/db/queries/loudness.sql @@ -67,11 +67,12 @@ SELECT count(*)::bigint AS total, SELECT * FROM loudness_settings WHERE id = true; -- name: UpdateLoudnessSettings :one --- Whole-row write from the admin card; migration 0065's CHECK is the backstop +-- Whole-row write from the admin card; the migrations' CHECKs are the backstop -- behind the service's own validation. UPDATE loudness_settings SET enabled = sqlc.arg(enabled), backfill_concurrency = sqlc.arg(backfill_concurrency), + leveled_cache_mb = sqlc.arg(leveled_cache_mb), updated_at = now() WHERE id = true RETURNING *; 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/db/queries/tracks.sql b/internal/db/queries/tracks.sql index e8075668..6d0b179e 100644 --- a/internal/db/queries/tracks.sql +++ b/internal/db/queries/tracks.sql @@ -26,9 +26,12 @@ RETURNING *; -- name: ListTracksMissingMbidWithPath :many -- Track recording-MBID backfill: tracks with NULL mbid that still have -- a file to re-read. $1 caps the batch (mirrors the album backfill). +-- Missing tracks are skipped: there is no file, and opening it anyway +-- logged a warning per track on every scan (#5139). SELECT id, file_path FROM tracks WHERE mbid IS NULL + AND missing_since IS NULL ORDER BY id LIMIT $1; 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/leveled.go b/internal/library/leveled.go new file mode 100644 index 00000000..b7abdd11 --- /dev/null +++ b/internal/library/leveled.go @@ -0,0 +1,358 @@ +package library + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io/fs" + "log/slog" + "math" + "os" + "os/exec" + "path/filepath" + "slices" + "strconv" + "strings" + "sync" + "time" + + "golang.org/x/sync/singleflight" +) + +// Leveled streams (M464 #5001). +// +// Sonos and other UPnP renderers fetch a track's URL themselves, so the phone +// that sent them there cannot change what they play. For them the server +// renders the track with its gain already applied: a FLAC, written to a cache +// directory and served as a plain file, so Content-Length, Range and seeking +// behave as for the original. Tags are stripped; the renderer is told the +// metadata in the DIDL-Lite it is handed, and an embedded ReplayGain tag would +// invite it to adjust a second time. + +// The same limits as the web and Android players (web/src/lib/player/gain.ts, +// android .../player/gain/GainMath.kt): headroom mode stops a boost 1 dB under +// the true peak, and no boost exceeds 12 dB. +const ( + leveledPeakCeilingDBTP = -1.0 + leveledMaxBoostDB = 12.0 + // The cut is bounded too: a gain is a request parameter, and a value no + // measurement could produce is refused rather than rendered. + leveledMaxCutDB = -60.0 +) + +// LeveledGainDB is the gain, in dB, a track gets under prefs: 0 when leveling +// is off or the track has not been measured. asAlbum says whether the track is +// being played as part of its album in order, which only the client that +// holds the queue can know. +func LeveledGainDB(prefs NormalizationPrefs, g ReplayGain, asAlbum bool) float64 { + if prefs.Mode == "off" { + return 0 + } + wantAlbum := prefs.Mode == "album" || (prefs.Mode == "auto" && asAlbum) + // Album gain falls back to track gain while the album is still being + // measured; track gain never falls back to album gain. + gain, peak := g.TrackGain, g.TrackPeak + if wantAlbum && g.AlbumGain != nil { + gain, peak = g.AlbumGain, g.AlbumPeak + } + if gain == nil { + return 0 + } + db := float64(*gain) + float64(prefs.TargetLUFS) - ReplayGainReferenceLUFS + if prefs.Boost == "headroom" && peak != nil && *peak > 0 { + db = math.Min(db, leveledPeakCeilingDBTP-20*math.Log10(float64(*peak))) + } + return math.Min(db, leveledMaxBoostDB) +} + +// LeveledGain is a render request: a gain in hundredths of a dB, and whether +// a limiter holds the peaks of a boost. Hundredths, so it travels in a URL and +// a signature exactly. +type LeveledGain struct { + CentiDB int + Limiter bool +} + +// NewLeveledGain rounds db to a render request. The limiter is only ever +// engaged for a boost: a cut cannot raise a peak. +func NewLeveledGain(db float64, boost string) LeveledGain { + c := int(math.Round(db * 100)) + return LeveledGain{CentiDB: c, Limiter: boost == "limiter" && c > 0} +} + +// Unity reports whether the request would reproduce the original. +func (g LeveledGain) Unity() bool { return g.CentiDB == 0 && !g.Limiter } + +// Valid reports whether the gain is one a measurement could have produced. +func (g LeveledGain) Valid() bool { + return g.CentiDB >= int(leveledMaxCutDB*100) && g.CentiDB <= int(leveledMaxBoostDB*100) +} + +// leveledFilter is the ffmpeg audio filter for g. The limiter's ceiling is +// -1 dBFS (0.891 linear); level=disabled stops alimiter from raising the +// output back to full scale afterwards, which would undo the leveling. +func leveledFilter(g LeveledGain) string { + f := "volume=" + strconv.FormatFloat(float64(g.CentiDB)/100, 'f', 2, 64) + "dB" + if g.Limiter { + f += ",alimiter=limit=0.891:level=disabled" + } + return f +} + +// leveledSource is what the renderer needs to know about the original file. +type leveledSource struct { + SampleRate int + // Bits is the source's sample depth; 0 for a lossy source, which has none. + Bits int +} + +// leveledRenderArgs is the ffmpeg command line rendering src to dst. Output is +// FLAC at the source's depth (24-bit for a hi-res source, 16 otherwise) and at +// most 48 kHz: renderers that take FLAC take those, and few take more. +func leveledRenderArgs(src, dst string, g LeveledGain, s leveledSource) []string { + args := []string{ + "-hide_banner", "-nostdin", "-nostats", "-loglevel", "error", + "-i", src, + "-map", "0:a:0", + "-map_metadata", "-1", + "-af", leveledFilter(g), + "-c:a", "flac", + } + if s.Bits > 16 { + args = append(args, "-sample_fmt", "s32", "-bits_per_raw_sample", "24") + } else { + args = append(args, "-sample_fmt", "s16") + } + if s.SampleRate > 48000 { + args = append(args, "-ar", "48000") + } + return append(args, "-f", "flac", "-y", dst) +} + +// leveledCacheKey names the rendered file. It changes with the source file's +// size and modification time, so a replaced file is never served from an old +// render, and with leveledRenderVersion, so a change to how renders are made +// retires the old ones. +func leveledCacheKey(trackID string, info fs.FileInfo, g LeveledGain) string { + h := sha256.New() + _, _ = fmt.Fprintf(h, "%d|%s|%d|%d|%d|%t", + leveledRenderVersion, trackID, info.Size(), info.ModTime().UnixNano(), g.CentiDB, g.Limiter) + return hex.EncodeToString(h.Sum(nil))[:32] + ".flac" +} + +const leveledRenderVersion = 1 + +// LeveledSource identifies the original a render is made from. +type LeveledSource struct { + TrackID string + Path string + DurationMs int32 +} + +// LeveledRenderer renders leveled FLACs into a cache directory and keeps that +// directory under the size the loudness settings allow. Safe for concurrent +// use: renders of the same file and gain are coalesced into one. +type LeveledRenderer struct { + dir string + settings *LoudnessSettingsService + logger *slog.Logger + group singleflight.Group + evictMu sync.Mutex + + // prerenders bounds the background renders running at once. A fetch + // renders on demand regardless, so a prerender that finds no slot is + // dropped, not queued. + prerenders chan struct{} + + // render and probe are ffmpeg and ffprobe; tests replace them. + render func(ctx context.Context, src, dst string, g LeveledGain, s leveledSource) error + probe func(ctx context.Context, src string) (leveledSource, error) +} + +// NewLeveledRenderer renders into dir, creating it if needed. +func NewLeveledRenderer(dir string, settings *LoudnessSettingsService, logger *slog.Logger) (*LeveledRenderer, error) { + if err := os.MkdirAll(dir, 0o750); err != nil { + return nil, fmt.Errorf("leveled cache: %w", err) + } + return &LeveledRenderer{ + dir: dir, + settings: settings, + logger: logger, + prerenders: make(chan struct{}, maxLeveledPrerenders), + render: ffmpegRenderLeveled, + probe: ffprobeLeveledSource, + }, nil +} + +// ErrLeveledSourceMissing is returned when the original file cannot be read. +var ErrLeveledSourceMissing = errors.New("leveled: source file missing") + +// Path returns the rendered file for src at gain g, rendering it first if it +// is not cached. A caller arriving while the same render runs waits for it +// rather than starting another. +func (r *LeveledRenderer) Path(ctx context.Context, src LeveledSource, g LeveledGain) (string, error) { + info, err := os.Stat(src.Path) + if err != nil { + return "", fmt.Errorf("%w: %w", ErrLeveledSourceMissing, err) + } + dst := filepath.Join(r.dir, leveledCacheKey(src.TrackID, info, g)) + if _, err := os.Stat(dst); err == nil { + // A hit counts as a use, for eviction. + now := time.Now() + _ = os.Chtimes(dst, now, now) + return dst, nil + } + // The render runs detached from the caller: a renderer that gives up on + // one request (Sonos retries quickly) must not cancel the render a second + // request is about to wait on. + ch := r.group.DoChan(dst, func() (any, error) { + rctx, cancel := context.WithTimeout(context.Background(), loudnessTimeout(src.DurationMs)) + defer cancel() + return dst, r.renderTo(rctx, src.Path, dst, g) + }) + select { + case res := <-ch: + if res.Err != nil { + return "", res.Err + } + return dst, nil + case <-ctx.Done(): + return "", ctx.Err() + } +} + +// maxLeveledPrerenders is how many prerenders may run at once. A speaker +// needs the track it is about to play and the one after; more than that is a +// client asking for too much, and the excess is dropped. +const maxLeveledPrerenders = 2 + +// Prerender starts rendering src at g in the background, so the speaker's +// fetch finds it ready. It reports false, and does nothing, when the +// prerender slots are full. +func (r *LeveledRenderer) Prerender(src LeveledSource, g LeveledGain) bool { + select { + case r.prerenders <- struct{}{}: + default: + r.logger.Debug("leveled: prerender dropped, slots full", "track", src.TrackID) + return false + } + go func() { + defer func() { <-r.prerenders }() + ctx, cancel := context.WithTimeout(context.Background(), loudnessTimeout(src.DurationMs)) + defer cancel() + if _, err := r.Path(ctx, src, g); err != nil { + r.logger.Warn("leveled: prerender failed", "track", src.TrackID, "err", err) + } + }() + return true +} + +func (r *LeveledRenderer) renderTo(ctx context.Context, src, dst string, g LeveledGain) error { + s, err := r.probe(ctx, src) + if err != nil { + return fmt.Errorf("leveled: probe: %w", err) + } + // Rendered beside the destination and renamed into place, so a reader + // never sees a half-written file and a failed render leaves nothing. + tmp := dst + ".part" + if err := r.render(ctx, src, tmp, g, s); err != nil { + _ = os.Remove(tmp) + return fmt.Errorf("leveled: render: %w", err) + } + if err := os.Rename(tmp, dst); err != nil { + _ = os.Remove(tmp) + return fmt.Errorf("leveled: %w", err) + } + r.evict(dst) + return nil +} + +// evict removes the least recently used renders until the cache fits the +// configured size. keep is never removed: it is the render about to be served. +func (r *LeveledRenderer) evict(keep string) { + r.evictMu.Lock() + defer r.evictMu.Unlock() + limit := int64(r.settings.Get().LeveledCacheMB) << 20 + entries, err := os.ReadDir(r.dir) + if err != nil { + r.logger.Warn("leveled: read cache", "err", err) + return + } + type cached struct { + path string + size int64 + used time.Time + } + var files []cached + var total int64 + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".flac") { + continue + } + info, err := e.Info() + if err != nil { + continue + } + files = append(files, cached{filepath.Join(r.dir, e.Name()), info.Size(), info.ModTime()}) + total += info.Size() + } + slices.SortFunc(files, func(a, b cached) int { return a.used.Compare(b.used) }) + for _, f := range files { + if total <= limit { + return + } + if f.path == keep { + continue + } + // A file still being sent stays readable: the open descriptor holds it. + if err := os.Remove(f.path); err == nil { + total -= f.size + } + } +} + +func ffmpegRenderLeveled(ctx context.Context, src, dst string, g LeveledGain, s leveledSource) error { + out, err := exec.CommandContext(ctx, "ffmpeg", leveledRenderArgs(src, dst, g, s)...).CombinedOutput() + if err != nil { + return fmt.Errorf("ffmpeg: %w: %s", err, strings.TrimSpace(string(out))) + } + return nil +} + +// ffprobeLeveledSource reads the first audio stream's rate and depth. +// bits_per_raw_sample is set for lossless codecs and 0 or N/A for lossy ones. +func ffprobeLeveledSource(ctx context.Context, src string) (leveledSource, error) { + out, err := exec.CommandContext(ctx, "ffprobe", + "-v", "error", "-select_streams", "a:0", + "-show_entries", "stream=sample_rate,bits_per_raw_sample,bits_per_sample", + "-of", "default=noprint_wrappers=1", src).Output() + if err != nil { + return leveledSource{}, fmt.Errorf("ffprobe: %w", err) + } + return parseLeveledProbe(string(out)), nil +} + +// parseLeveledProbe reads ffprobe's key=value lines. Unknown or N/A values +// read as 0: a 16-bit, at-most-48 kHz render, which suits every source. +func parseLeveledProbe(out string) leveledSource { + var s leveledSource + for _, line := range strings.Split(out, "\n") { + k, v, ok := strings.Cut(strings.TrimSpace(line), "=") + if !ok { + continue + } + n, err := strconv.Atoi(v) + if err != nil { + continue + } + switch k { + case "sample_rate": + s.SampleRate = n + case "bits_per_raw_sample", "bits_per_sample": + s.Bits = max(s.Bits, n) + } + } + return s +} diff --git a/internal/library/leveled_test.go b/internal/library/leveled_test.go new file mode 100644 index 00000000..7b8c49c2 --- /dev/null +++ b/internal/library/leveled_test.go @@ -0,0 +1,279 @@ +package library + +import ( + "context" + "errors" + "io" + "log/slog" + "math" + "os" + "path/filepath" + "slices" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +func f32(v float32) *float32 { return &v } + +// The same cases as web/src/lib/player/gain.test.ts and Android's +// GainMathTest: a speaker must level a track exactly as the phone would. +func TestLeveledGainDB(t *testing.T) { + g := ReplayGain{TrackGain: f32(-6), TrackPeak: f32(1), AlbumGain: f32(-4), AlbumPeak: f32(1)} + auto := DefaultNormalizationPrefs + with := func(mode string, target int16, boost string) NormalizationPrefs { + return NormalizationPrefs{Mode: mode, TargetLUFS: target, Boost: boost} + } + cases := []struct { + name string + prefs NormalizationPrefs + g ReplayGain + asAlbum bool + want float64 + }{ + {"off", with("off", -18, "headroom"), g, false, 0}, + {"unmeasured", auto, ReplayGain{}, false, 0}, + {"track mode ignores album play", with("track", -18, "headroom"), g, true, -6}, + {"album mode", with("album", -18, "headroom"), g, false, -4}, + {"auto in album order", auto, g, true, -4}, + {"auto in a mix", auto, g, false, -6}, + {"album falls back to track", with("album", -18, "headroom"), ReplayGain{TrackGain: f32(-6), TrackPeak: f32(1)}, true, -6}, + {"louder target", with("track", -14, "headroom"), g, false, -2}, + {"headroom stops under the peak", with("track", -18, "headroom"), ReplayGain{TrackGain: f32(8), TrackPeak: f32(0.5)}, false, -1 - 20*math.Log10(0.5)}, + {"limiter lets the boost through", with("track", -18, "limiter"), ReplayGain{TrackGain: f32(8), TrackPeak: f32(0.5)}, false, 8}, + {"boost cap", with("track", -18, "limiter"), ReplayGain{TrackGain: f32(30), TrackPeak: f32(0.001)}, false, 12}, + {"cuts ignore the peak", with("track", -18, "headroom"), ReplayGain{TrackGain: f32(-9), TrackPeak: f32(1.4)}, false, -9}, + } + for _, c := range cases { + if got := LeveledGainDB(c.prefs, c.g, c.asAlbum); math.Abs(got-c.want) > 1e-4 { + t.Errorf("%s: gain = %.4f, want %.4f", c.name, got, c.want) + } + } +} + +func TestNewLeveledGain(t *testing.T) { + if g := NewLeveledGain(-6.126, "limiter"); g.CentiDB != -613 || g.Limiter { + t.Errorf("cut = %+v, want -613 without the limiter: a cut cannot raise a peak", g) + } + if g := NewLeveledGain(3.2, "limiter"); g.CentiDB != 320 || !g.Limiter { + t.Errorf("boost = %+v, want 320 with the limiter", g) + } + if g := NewLeveledGain(3.2, "headroom"); g.Limiter { + t.Errorf("headroom boost engaged the limiter: %+v", g) + } + if !NewLeveledGain(0.001, "limiter").Unity() { + t.Error("a gain that rounds to 0 should be unity") + } + for _, c := range []struct { + g LeveledGain + want bool + }{{LeveledGain{CentiDB: 1200}, true}, {LeveledGain{CentiDB: 1201}, false}, {LeveledGain{CentiDB: -6000}, true}, {LeveledGain{CentiDB: -6001}, false}} { + if c.g.Valid() != c.want { + t.Errorf("Valid(%d) = %v, want %v", c.g.CentiDB, !c.want, c.want) + } + } +} + +func TestLeveledRenderArgs(t *testing.T) { + join := func(a []string) string { return strings.Join(a, " ") } + + hires := join(leveledRenderArgs("in.flac", "out.flac", LeveledGain{CentiDB: 350, Limiter: true}, leveledSource{SampleRate: 96000, Bits: 24})) + for _, want := range []string{ + "-map_metadata -1", + "-af volume=3.50dB,alimiter=limit=0.891:level=disabled", + "-c:a flac", + "-sample_fmt s32 -bits_per_raw_sample 24", + "-ar 48000", + "-f flac -y out.flac", + } { + if !strings.Contains(hires, want) { + t.Errorf("hi-res args %q lack %q", hires, want) + } + } + + lossy := join(leveledRenderArgs("in.mp3", "out.flac", LeveledGain{CentiDB: -612}, leveledSource{SampleRate: 44100})) + if !strings.Contains(lossy, "-af volume=-6.12dB -c:a") || strings.Contains(lossy, "alimiter") { + t.Errorf("cut args %q: want a plain volume filter", lossy) + } + if !strings.Contains(lossy, "-sample_fmt s16") || strings.Contains(lossy, "-ar ") { + t.Errorf("lossy 44.1 kHz args %q: want 16-bit at the source rate", lossy) + } +} + +func TestParseLeveledProbe(t *testing.T) { + got := parseLeveledProbe("sample_rate=96000\nbits_per_sample=0\nbits_per_raw_sample=24\n") + if got != (leveledSource{SampleRate: 96000, Bits: 24}) { + t.Errorf("flac probe = %+v", got) + } + got = parseLeveledProbe("sample_rate=44100\nbits_per_sample=0\nbits_per_raw_sample=N/A\n") + if got != (leveledSource{SampleRate: 44100}) { + t.Errorf("mp3 probe = %+v", got) + } +} + +// testRenderer renders by writing size bytes, counting the renders it runs. +func testRenderer(t *testing.T, cacheMB int32, size int) (*LeveledRenderer, *atomic.Int32, chan struct{}) { + t.Helper() + settings := &LoudnessSettingsService{cur: DefaultLoudnessSettings} + settings.cur.LeveledCacheMB = cacheMB + r, err := NewLeveledRenderer(t.TempDir(), settings, slog.New(slog.NewTextHandler(io.Discard, nil))) + if err != nil { + t.Fatal(err) + } + var renders atomic.Int32 + gate := make(chan struct{}) + close(gate) // open unless a test replaces it + r.probe = func(context.Context, string) (leveledSource, error) { return leveledSource{}, nil } + r.render = func(_ context.Context, _, dst string, _ LeveledGain, _ leveledSource) error { + <-gate + renders.Add(1) + return os.WriteFile(dst, make([]byte, size), 0o600) + } + return r, &renders, gate +} + +func writeSource(t *testing.T, name string) LeveledSource { + t.Helper() + p := filepath.Join(t.TempDir(), name) + if err := os.WriteFile(p, []byte("audio"), 0o600); err != nil { + t.Fatal(err) + } + return LeveledSource{TrackID: name, Path: p, DurationMs: 1000} +} + +func TestLeveledRenderer_CoalescesAndCaches(t *testing.T) { + r, renders, _ := testRenderer(t, 2048, 10) + gate := make(chan struct{}) + r.render = func(_ context.Context, _, dst string, _ LeveledGain, _ leveledSource) error { + <-gate + renders.Add(1) + return os.WriteFile(dst, []byte("flac"), 0o600) + } + src := writeSource(t, "a") + g := LeveledGain{CentiDB: -300} + + var wg sync.WaitGroup + paths := make([]string, 5) + for i := range paths { + wg.Add(1) + go func() { + defer wg.Done() + p, err := r.Path(context.Background(), src, g) + if err != nil { + t.Errorf("Path: %v", err) + } + paths[i] = p + }() + } + time.Sleep(50 * time.Millisecond) // let every caller reach the render + close(gate) + wg.Wait() + if n := renders.Load(); n != 1 { + t.Fatalf("5 concurrent requests ran %d renders, want 1", n) + } + for _, p := range paths { + if p != paths[0] { + t.Fatalf("callers got different files: %v", paths) + } + } + + if _, err := r.Path(context.Background(), src, g); err != nil || renders.Load() != 1 { + t.Fatalf("a cached render was rendered again (renders %d, err %v)", renders.Load(), err) + } + if _, err := r.Path(context.Background(), src, LeveledGain{CentiDB: -200}); err != nil || renders.Load() != 2 { + t.Fatalf("a different gain did not render anew (renders %d, err %v)", renders.Load(), err) + } + // A replaced file is a different source: the old render must not serve it. + later := time.Now().Add(time.Hour) + if err := os.Chtimes(src.Path, later, later); err != nil { + t.Fatal(err) + } + if _, err := r.Path(context.Background(), src, g); err != nil || renders.Load() != 3 { + t.Fatalf("a changed source was served from the old render (renders %d, err %v)", renders.Load(), err) + } +} + +// Prerenders past the slot limit are dropped rather than piling up ffmpeg +// processes; a slot frees when its render ends. +func TestLeveledRenderer_PrerenderSlots(t *testing.T) { + r, renders, _ := testRenderer(t, 2048, 10) + gate := make(chan struct{}) + r.render = func(_ context.Context, _, dst string, _ LeveledGain, _ leveledSource) error { + <-gate + renders.Add(1) + return os.WriteFile(dst, []byte("flac"), 0o600) + } + g := LeveledGain{CentiDB: -300} + for i := range maxLeveledPrerenders { + if !r.Prerender(writeSource(t, string(rune('a'+i))), g) { + t.Fatalf("prerender %d refused with slots free", i) + } + } + if r.Prerender(writeSource(t, "z"), g) { + t.Fatal("a prerender past the limit was accepted") + } + close(gate) + deadline := time.Now().Add(5 * time.Second) + for !r.Prerender(writeSource(t, "y"), g) { + if time.Now().After(deadline) { + t.Fatal("no slot freed after the renders finished") + } + time.Sleep(10 * time.Millisecond) + } + for renders.Load() != maxLeveledPrerenders+1 { + if time.Now().After(deadline) { + t.Fatalf("renders = %d, want %d", renders.Load(), maxLeveledPrerenders+1) + } + time.Sleep(10 * time.Millisecond) + } +} + +func TestLeveledRenderer_MissingSource(t *testing.T) { + r, _, _ := testRenderer(t, 2048, 10) + _, err := r.Path(context.Background(), LeveledSource{TrackID: "x", Path: "/nonexistent/x.flac"}, LeveledGain{CentiDB: 100}) + if !errors.Is(err, ErrLeveledSourceMissing) { + t.Fatalf("err = %v, want ErrLeveledSourceMissing", err) + } +} + +func TestLeveledRenderer_FailedRenderLeavesNothing(t *testing.T) { + r, _, _ := testRenderer(t, 2048, 10) + r.render = func(_ context.Context, _, dst string, _ LeveledGain, _ leveledSource) error { + _ = os.WriteFile(dst, []byte("half"), 0o600) + return errors.New("ffmpeg exited 1") + } + if _, err := r.Path(context.Background(), writeSource(t, "a"), LeveledGain{CentiDB: 100}); err == nil { + t.Fatal("a failed render reported success") + } + if entries, _ := os.ReadDir(r.dir); len(entries) != 0 { + t.Fatalf("a failed render left %d files behind", len(entries)) + } +} + +func TestLeveledRenderer_EvictsLeastRecentlyUsed(t *testing.T) { + // 1 MB cap, 400 KB renders: the third render pushes out the oldest. + r, _, _ := testRenderer(t, 1, 400<<10) + a, b, c := writeSource(t, "a"), writeSource(t, "b"), writeSource(t, "c") + g := LeveledGain{CentiDB: 100} + pa, _ := r.Path(context.Background(), a, g) + past := time.Now().Add(-time.Hour) + _ = os.Chtimes(pa, past, past) + pb, _ := r.Path(context.Background(), b, g) + _ = os.Chtimes(pb, past.Add(time.Minute), past.Add(time.Minute)) + // Using a again makes b the least recently used. + if _, err := r.Path(context.Background(), a, g); err != nil { + t.Fatal(err) + } + pc, _ := r.Path(context.Background(), c, g) + + var left []string + entries, _ := os.ReadDir(r.dir) + for _, e := range entries { + left = append(left, filepath.Join(r.dir, e.Name())) + } + if slices.Contains(left, pb) || !slices.Contains(left, pa) || !slices.Contains(left, pc) { + t.Fatalf("cache after eviction = %v; want a and c kept, b (least recently used) gone", left) + } +} diff --git a/internal/library/loudness_settings.go b/internal/library/loudness_settings.go index 31beca20..7f6f73d8 100644 --- a/internal/library/loudness_settings.go +++ b/internal/library/loudness_settings.go @@ -22,17 +22,27 @@ type LoudnessSettings struct { // values, so normalization keeps working for them. Enabled bool BackfillConcurrency int32 + // LeveledCacheMB caps the disk the leveled-stream renders for Sonos and + // UPnP speakers may use (#5001); the least recently played go first. + LeveledCacheMB int32 // UpdatedAt is set by the database; ignored by Set. UpdatedAt time.Time } -// DefaultLoudnessSettings mirrors migration 0065's column defaults, so a +// DefaultLoudnessSettings mirrors migrations 0065 and 0068's column defaults, so a // database that cannot be read still analyzes the way a fresh install does. var DefaultLoudnessSettings = LoudnessSettings{ Enabled: true, BackfillConcurrency: loudnessBackfillConcurrency, + LeveledCacheMB: 2048, } +// The leveled-stream cache's bounds, as migration 0068's CHECK has them. +const ( + minLeveledCacheMB = 256 + maxLeveledCacheMB = 65536 +) + // ErrLoudnessSettingOutOfRange is returned by Set for a value migration 0065's // CHECK would reject, so the API answers 400 naming the field. var ErrLoudnessSettingOutOfRange = errors.New("loudness setting out of range") @@ -78,6 +88,7 @@ func (s *LoudnessSettingsService) Set(ctx context.Context, in LoudnessSettings) row, err := dbq.New(s.pool).UpdateLoudnessSettings(ctx, dbq.UpdateLoudnessSettingsParams{ Enabled: in.Enabled, BackfillConcurrency: in.BackfillConcurrency, + LeveledCacheMb: in.LeveledCacheMB, }) if err != nil { return LoudnessSettings{}, fmt.Errorf("loudness settings: save: %w", err) @@ -94,6 +105,10 @@ func validateLoudnessSettings(in LoudnessSettings) error { return fmt.Errorf("%w: backfill_concurrency must be %d-%d", ErrLoudnessSettingOutOfRange, minBackfillConcurrency, maxBackfillConcurrency) } + if in.LeveledCacheMB < minLeveledCacheMB || in.LeveledCacheMB > maxLeveledCacheMB { + return fmt.Errorf("%w: leveled_cache_mb must be %d-%d", + ErrLoudnessSettingOutOfRange, minLeveledCacheMB, maxLeveledCacheMB) + } return nil } @@ -101,6 +116,7 @@ func loudnessSettingsFromRow(row dbq.LoudnessSetting) LoudnessSettings { return LoudnessSettings{ Enabled: row.Enabled, BackfillConcurrency: row.BackfillConcurrency, + LeveledCacheMB: row.LeveledCacheMb, UpdatedAt: row.UpdatedAt.Time, } } diff --git a/internal/library/loudness_test.go b/internal/library/loudness_test.go index d2f40704..6d4ac4da 100644 --- a/internal/library/loudness_test.go +++ b/internal/library/loudness_test.go @@ -233,14 +233,27 @@ func TestBackfillLoudnessResult_Add(t *testing.T) { } func TestValidateLoudnessSettings(t *testing.T) { - for _, n := range []int32{minBackfillConcurrency, maxBackfillConcurrency} { - if err := validateLoudnessSettings(LoudnessSettings{BackfillConcurrency: n}); err != nil { - t.Errorf("concurrency %d rejected: %v", n, err) + with := func(concurrency, cacheMB int32) LoudnessSettings { + s := DefaultLoudnessSettings + s.BackfillConcurrency, s.LeveledCacheMB = concurrency, cacheMB + return s + } + for _, s := range []LoudnessSettings{ + with(minBackfillConcurrency, minLeveledCacheMB), + with(maxBackfillConcurrency, maxLeveledCacheMB), + } { + if err := validateLoudnessSettings(s); err != nil { + t.Errorf("%+v rejected: %v", s, err) } } - for _, n := range []int32{0, maxBackfillConcurrency + 1} { - if err := validateLoudnessSettings(LoudnessSettings{BackfillConcurrency: n}); !errors.Is(err, ErrLoudnessSettingOutOfRange) { - t.Errorf("concurrency %d: err = %v, want ErrLoudnessSettingOutOfRange", n, err) + for _, s := range []LoudnessSettings{ + with(0, minLeveledCacheMB), + with(maxBackfillConcurrency+1, minLeveledCacheMB), + with(minBackfillConcurrency, minLeveledCacheMB-1), + with(minBackfillConcurrency, maxLeveledCacheMB+1), + } { + if err := validateLoudnessSettings(s); !errors.Is(err, ErrLoudnessSettingOutOfRange) { + t.Errorf("%+v: err = %v, want ErrLoudnessSettingOutOfRange", s, err) } } var nilSvc *LoudnessSettingsService diff --git a/internal/library/mbidbackfill_test.go b/internal/library/mbidbackfill_test.go new file mode 100644 index 00000000..a645abe4 --- /dev/null +++ b/internal/library/mbidbackfill_test.go @@ -0,0 +1,71 @@ +package library + +import ( + "bytes" + "context" + "log/slog" + "os" + "path/filepath" + "strings" + "testing" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +// The MBID backfills read tags from files, so a track whose file is gone has +// nothing to give them. Before #5139 they opened it anyway, and every scan +// logged an "open failed" warning per missing track. +func TestMBIDBackfill_SkipsMissingTracks_Integration(t *testing.T) { + pool := newPool(t) + ctx := context.Background() + q := dbq.New(pool) + dir := t.TempDir() + + // The album's first track is missing; its second is on disk, untagged. + gone, album, artist := seedTrack(t, pool, filepath.Join(dir, "gone.mp3")) + present := filepath.Join(dir, "present.mp3") + if err := os.WriteFile(present, []byte("not really audio"), 0o600); err != nil { + t.Fatal(err) + } + two := int32(2) + if _, err := q.UpsertTrack(ctx, dbq.UpsertTrackParams{ + Title: "Present", AlbumID: album.ID, ArtistID: artist.ID, TrackNumber: &two, + DurationMs: 1000, FilePath: present, FileSize: 100, FileFormat: "mp3", + }); err != nil { + t.Fatalf("track: %v", err) + } + if _, err := pool.Exec(ctx, "UPDATE tracks SET missing_since = now() WHERE id = $1", gone.ID); err != nil { + t.Fatalf("mark missing: %v", err) + } + + tracks, err := q.ListTracksMissingMbidWithPath(ctx, 100) + if err != nil { + t.Fatal(err) + } + if len(tracks) != 1 || tracks[0].FilePath != present { + t.Errorf("track backfill lists %+v, want only %s", tracks, present) + } + albums, err := q.ListAlbumsMissingMbidWithTrack(ctx, 100) + if err != nil { + t.Fatal(err) + } + if len(albums) != 1 || albums[0].TrackFilePath != present { + t.Errorf("album backfill reads %+v, want the album via %s", albums, present) + } + + // With every track gone, the album has no file to read and is skipped. + if _, err := pool.Exec(ctx, "UPDATE tracks SET missing_since = now() WHERE album_id = $1", album.ID); err != nil { + t.Fatalf("mark missing: %v", err) + } + var logs bytes.Buffer + logger := slog.New(slog.NewTextHandler(&logs, nil)) + if res, err := BackfillTrackMBIDs(ctx, pool, logger, -1, nil); err != nil || res.Processed != 0 { + t.Errorf("track backfill processed %d (err %v), want 0", res.Processed, err) + } + if res, err := BackfillMBIDs(ctx, pool, logger, -1, nil); err != nil || res.Processed != 0 { + t.Errorf("album backfill processed %d (err %v), want 0", res.Processed, err) + } + if strings.Contains(logs.String(), "open failed") { + t.Errorf("a backfill opened a missing file:\n%s", logs.String()) + } +} 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/admin.ts b/web/src/lib/api/admin.ts index fa925e28..b257cb6d 100644 --- a/web/src/lib/api/admin.ts +++ b/web/src/lib/api/admin.ts @@ -386,6 +386,8 @@ export async function getLoudnessCoverage(): Promise { export type LoudnessSettings = { enabled: boolean; backfill_concurrency: number; + /** Disk for the leveled copies rendered for Sonos/UPnP speakers (#5001). */ + leveled_cache_mb: number; }; export async function getLoudnessSettings(): Promise { diff --git a/web/src/lib/api/normalization.ts b/web/src/lib/api/normalization.ts new file mode 100644 index 00000000..9c7db924 --- /dev/null +++ b/web/src/lib/api/normalization.ts @@ -0,0 +1,62 @@ +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. +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); +} + +// 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/components/LoudnessSettingsCard.svelte b/web/src/lib/components/LoudnessSettingsCard.svelte index 6003f1df..7d62066a 100644 --- a/web/src/lib/components/LoudnessSettingsCard.svelte +++ b/web/src/lib/components/LoudnessSettingsCard.svelte @@ -11,7 +11,8 @@ import { pushToast } from '$lib/stores/toast.svelte'; // Loudness analysis (#4995): the background measurement that loudness - // normalization levels playback from, its progress, and its two knobs. + // normalization levels playback from, its progress, and its knobs. The + // cache size bounds the leveled copies rendered for speakers (#5001). let saved = $state(null); let form = $state(null); @@ -26,6 +27,13 @@ form.backfill_concurrency >= 1 && form.backfill_concurrency <= 8 ); + const cacheOk = $derived( + !!form && + Number.isInteger(form.leveled_cache_mb) && + form.leveled_cache_mb >= 256 && + form.leveled_cache_mb <= 65536 + ); + const valid = $derived(concurrencyOk && cacheOk); async function load() { try { @@ -55,7 +63,7 @@ }); async function save() { - if (!form || !concurrencyOk) return; + if (!form || !valid) return; saving = true; try { saved = await updateLoudnessSettings(form); @@ -151,10 +159,26 @@ /> - {#if !concurrencyOk} -

- Files analyzed at once must be a whole number from 1 to 8. -

+ + + {#if !valid} +
+ {#if !concurrencyOk}

Files analyzed at once must be a whole number from 1 to 8.

{/if} + {#if !cacheOk}

Speaker cache must be a whole number of MB from 256 to 65536.

{/if} +
{/if}
@@ -163,7 +187,7 @@ class="rounded-md bg-action-secondary px-4 py-2 text-sm text-action-fg hover:opacity-90 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent disabled:cursor-not-allowed disabled:opacity-50" - disabled={!dirty || saving || !concurrencyOk} + disabled={!dirty || saving || !valid} onclick={save} > {saving ? 'Saving…' : 'Save'} diff --git a/web/src/lib/components/LoudnessSettingsCard.test.ts b/web/src/lib/components/LoudnessSettingsCard.test.ts index 670676ad..f1bbca8e 100644 --- a/web/src/lib/components/LoudnessSettingsCard.test.ts +++ b/web/src/lib/components/LoudnessSettingsCard.test.ts @@ -14,7 +14,7 @@ import LoudnessSettingsCard from './LoudnessSettingsCard.svelte'; import { getLoudnessCoverage, getLoudnessSettings, updateLoudnessSettings } from '$lib/api/admin'; import { pushToast } from '$lib/stores/toast.svelte'; -const base: LoudnessSettings = { enabled: true, backfill_concurrency: 2 }; +const base: LoudnessSettings = { enabled: true, backfill_concurrency: 2, leveled_cache_mb: 2048 }; const coverage: LoudnessCoverage = { total: 1200, measured: 900, @@ -68,7 +68,11 @@ describe('LoudnessSettingsCard', () => { await fireEvent.click(saveButton()); await waitFor(() => - expect(updateLoudnessSettings).toHaveBeenCalledWith({ enabled: true, backfill_concurrency: 4 }) + expect(updateLoudnessSettings).toHaveBeenCalledWith({ + enabled: true, + backfill_concurrency: 4, + leveled_cache_mb: 2048 + }) ); await waitFor(() => expect(pushToast).toHaveBeenCalledWith('Loudness analysis settings saved.')); // The gauge is read again after a save: switching analysis on or off changes it. @@ -84,6 +88,16 @@ describe('LoudnessSettingsCard', () => { expect(saveButton()).toHaveProperty('disabled', true); }); + test('the speaker cache size is bounded too', async () => { + await renderCard(); + const cache = screen.getByRole('spinbutton', { name: /speaker cache/i }); + await fireEvent.input(cache, { target: { value: '100' } }); + await waitFor(() => + expect(screen.getByTestId('settings-problems').textContent).toMatch(/256 to 65536/) + ); + expect(saveButton()).toHaveProperty('disabled', true); + }); + test('a failed load offers a retry', async () => { vi.mocked(getLoudnessSettings).mockRejectedValue(new Error('boom')); vi.mocked(getLoudnessCoverage).mockResolvedValue(coverage); diff --git a/web/src/lib/components/NormalizationSetting.svelte b/web/src/lib/components/NormalizationSetting.svelte new file mode 100644 index 00000000..e6bb8ab5 --- /dev/null +++ b/web/src/lib/components/NormalizationSetting.svelte @@ -0,0 +1,106 @@ + + +
+
+ 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..eb671d6b --- /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('switch', { 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('switch')).toBeNull(); + }); +}); diff --git a/web/src/lib/player/gain.test.ts b/web/src/lib/player/gain.test.ts new file mode 100644 index 00000000..2f6db039 --- /dev/null +++ b/web/src/lib/player/gain.test.ts @@ -0,0 +1,87 @@ +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); + expect(playingAsAlbum([track('1', 'x'), track('2', 'x', 3)], 1)).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..0fd81e7d --- /dev/null +++ b/web/src/lib/player/gain.ts @@ -0,0 +1,68 @@ +// Loudness normalization math for the web player (M464 #4999). Pure, so the +// rules are tested without an
+ +
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(),