M464 loudness leveling (steps 4–8), APK distribution and security baseline adoptions #136

Merged
bvandeusen merged 12 commits from dev into main 2026-10-06 23:11:36 -04:00
95 changed files with 4196 additions and 92 deletions
+46 -17
View File
@@ -272,8 +272,12 @@ jobs:
# every integration package TRUNCATEs the one shared test DB; # every integration package TRUNCATEs the one shared test DB;
# concurrent package binaries → TRUNCATE deadlocks. Serialize # concurrent package binaries → TRUNCATE deadlocks. Serialize
# package execution (the documented local invocation too). # 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 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: web:
runs-on: go-ci runs-on: go-ci
@@ -352,22 +356,11 @@ jobs:
- name: Unit tests - name: Unit tests
run: ./gradlew testDebugUnitTest run: ./gradlew testDebugUnitTest
- name: Assemble debug # No debug APK is built or uploaded here. Main used to upload a
if: github.event_name == 'push' && github.ref == 'refs/heads/main' # debug-signed app-debug.apk: a build signed by a key regenerated in
run: ./gradlew assembleDebug # every container, which no install can update (family idea #5103,
# practice 2). Phones get builds from android-release, signed with
- name: Upload debug APK # the one release key, on dev and on tags.
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
# Known vulnerabilities in the Go code and the standard library it is built # 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 # 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_NAME=${{ steps.ver.outputs.name }} \
-PMINSTREL_VERSION_CODE=${{ steps.ver.outputs.code }} -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 - name: Upload APK as workflow artifact
# Stock action (snippet #2271) — never @v3, which uploads something Gitea # Stock action (snippet #2271) — never @v3, which uploads something Gitea
# will never serve back. This is the producing half of a pair: # will never serve back. This is the producing half of a pair:
+5 -1
View File
@@ -72,9 +72,13 @@ android {
getDefaultProguardFile("proguard-android-optimize.txt"), getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro", "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 = signingConfig =
if (System.getenv("ANDROID_KEYSTORE_PATH").isNullOrEmpty()) { if (System.getenv("ANDROID_KEYSTORE_PATH").isNullOrEmpty()) {
signingConfigs.getByName("debug") null
} else { } else {
signingConfigs.getByName("release") signingConfigs.getByName("release")
} }
@@ -50,7 +50,14 @@ class BaseUrlInterceptor @Inject constructor(
.port(baseUrl.port) .port(baseUrl.port)
.build() .build()
} ?: original.url } ?: 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 { companion object {
@@ -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
}
@@ -38,6 +38,7 @@ object ErrorCopy {
*/ */
fun fromThrowable(t: Throwable): String = when (t) { fun fromThrowable(t: Throwable): String = when (t) {
is HttpException -> fromHttp(t) is HttpException -> fromHttp(t)
is CleartextToPublicHostException -> messageFor("cleartext_public")
is IOException -> messageFor("connection_refused") is IOException -> messageFor("connection_refused")
else -> TABLE.getValue("unknown") else -> TABLE.getValue("unknown")
} }
@@ -101,6 +102,9 @@ object ErrorCopy {
"mbid_required" to "An MBID is required for this lookup.", "mbid_required" to "An MBID is required for this lookup.",
"system_playlist_readonly" to "System playlists can't be edited directly.", "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.", "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_unreachable" to
"Lidarr is unreachable right now. Try again, or check Admin → Integrations.", "Lidarr is unreachable right now. Try again, or check Admin → Integrations.",
"lidarr_disabled" to "Lidarr integration is not enabled.", "lidarr_disabled" to "Lidarr integration is not enabled.",
@@ -70,6 +70,9 @@ object NetworkModule {
.addInterceptor(auth) .addInterceptor(auth)
.addInterceptor(baseUrl) .addInterceptor(baseUrl)
.addInterceptor(logging) .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) .connectTimeout(CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.readTimeout(READ_TIMEOUT_SECONDS, TimeUnit.SECONDS) .readTimeout(READ_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.build() .build()
@@ -29,11 +29,20 @@ interface CastApi {
* Request body. [expSeconds] is clamped server-side to [60, 86400]; * Request body. [expSeconds] is clamped server-side to [60, 86400];
* the 21_600 default (6h) is long enough to play through any typical * the 21_600 default (6h) is long enough to play through any typical
* track without re-minting mid-playback. * 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 @Serializable
data class StreamTokenRequest( data class StreamTokenRequest(
val trackId: String, val trackId: String,
val expSeconds: Int = 21_600, 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 url: String,
val mime: String = "audio/mpeg", val mime: String = "audio/mpeg",
val title: String = "", val title: String = "",
/** [url] is the leveled stream; false when leveling is off or changes nothing. */
val leveled: Boolean = false,
) )
@@ -3,6 +3,7 @@ package com.fabledsword.minstrel.api.endpoints
import com.fabledsword.minstrel.models.wire.ListenBrainzStatusWire import com.fabledsword.minstrel.models.wire.ListenBrainzStatusWire
import com.fabledsword.minstrel.models.wire.MyProfileWire import com.fabledsword.minstrel.models.wire.MyProfileWire
import com.fabledsword.minstrel.models.wire.SystemPlaylistsStatusWire import com.fabledsword.minstrel.models.wire.SystemPlaylistsStatusWire
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import retrofit2.http.Body import retrofit2.http.Body
@@ -59,6 +60,14 @@ interface MeApi {
*/ */
@PUT("api/me/listenbrainz") @PUT("api/me/listenbrainz")
suspend fun setListenBrainz(@Body body: ListenBrainzPutBody): ListenBrainzStatusWire 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
} }
/** /**
@@ -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
}
@@ -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.dao.AuthSessionDao
import com.fabledsword.minstrel.cache.db.entities.AuthSessionEntity import com.fabledsword.minstrel.cache.db.entities.AuthSessionEntity
import com.fabledsword.minstrel.di.ApplicationScope import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async import kotlinx.coroutines.async
@@ -77,6 +78,9 @@ class AuthStore @Inject constructor(
private val diagnosticsOptOutState = MutableStateFlow(false) private val diagnosticsOptOutState = MutableStateFlow(false)
val diagnosticsOptOut: StateFlow<Boolean> = diagnosticsOptOutState.asStateFlow() val diagnosticsOptOut: StateFlow<Boolean> = diagnosticsOptOutState.asStateFlow()
private val normalizationState = MutableStateFlow(NormalizationPrefs.DEFAULT)
val normalization: StateFlow<NormalizationPrefs> = normalizationState.asStateFlow()
private val json = Json { ignoreUnknownKeys = true } private val json = Json { ignoreUnknownKeys = true }
// Serialises every cookie persist with the one-time hydration, so a // Serialises every cookie persist with the one-time hydration, so a
@@ -99,6 +103,7 @@ class AuthStore @Inject constructor(
clientIdState.value = row?.clientId clientIdState.value = row?.clientId
cacheSettingsState.value = decodeCacheSettings(row?.cacheSettingsJson) cacheSettingsState.value = decodeCacheSettings(row?.cacheSettingsJson)
diagnosticsOptOutState.value = row?.diagnosticsOptOut ?: false diagnosticsOptOutState.value = row?.diagnosticsOptOut ?: false
normalizationState.value = decodeNormalization(row?.normalizationJson)
} }
} }
} }
@@ -110,6 +115,13 @@ class AuthStore @Inject constructor(
}.getOrDefault(CacheSettings.DEFAULT) }.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 * Suspends until the stored session cookie has been loaded into
* [sessionCookie], or [HYDRATION_DEADLINE_MS] passes (rule 156: a wedged * [sessionCookie], or [HYDRATION_DEADLINE_MS] passes (rule 156: a wedged
@@ -187,6 +199,12 @@ class AuthStore @Inject constructor(
scope.launch { persistDiagnosticsOptOut(value) } 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?) { private suspend fun persistLegacyCookie(value: String?) {
if (dao.get() == null) { if (dao.get() == null) {
dao.upsert(currentEntity().copy(sessionCookie = value)) 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( private fun currentEntity(): AuthSessionEntity = AuthSessionEntity(
id = ROW_ID, id = ROW_ID,
// Never copied into the row: the cookie lives in the vault, and // Never copied into the row: the cookie lives in the vault, and
@@ -257,6 +283,10 @@ class AuthStore @Inject constructor(
cacheSettingsState.value, cacheSettingsState.value,
), ),
diagnosticsOptOut = diagnosticsOptOutState.value, diagnosticsOptOut = diagnosticsOptOutState.value,
normalizationJson = json.encodeToString(
NormalizationPrefs.serializer(),
normalizationState.value,
),
) )
companion object { companion object {
@@ -3,6 +3,8 @@ package com.fabledsword.minstrel.cache.db
import androidx.room.Database import androidx.room.Database
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import androidx.room.TypeConverters 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.AudioCacheIndexDao
import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao
import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao
@@ -65,13 +67,22 @@ import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
AuthSessionEntity::class, AuthSessionEntity::class,
DiagnosticEventEntity::class, DiagnosticEventEntity::class,
], ],
// v10: + cached_tracks.trackGain/trackPeak and cached_albums.albumGain/
// albumPeak, the ReplayGain values the player levels by (M464 #5000).
// MIGRATION_9_10 also rewinds the sync cursor, so the next sync re-sends
// every row and an existing cache gains its values.
// v9: + auth_session.normalizationJson, the loudness-normalization
// 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), // v8: + cached_tracks.missing, the server's missing-file mark (#2704),
// so cache-first surfaces stop offering files that cannot stream. // so cache-first surfaces stop offering files that cannot stream.
// v7: + diagnostic_events table (M9) and the diagnosticsOptOut column // v7: + diagnostic_events table (M9) and the diagnosticsOptOut column
// on auth_session. Pre-v1 destructive fallback rebuilds on mismatch — // on auth_session. Pre-v1 destructive fallback rebuilds on mismatch —
// which is exactly right here: the next sync refills every row with the // which is exactly right here: the next sync refills every row with the
// new column populated, so there is nothing to migrate by hand. // new column populated, so there is nothing to migrate by hand.
version = 8, version = 10,
exportSchema = true, exportSchema = true,
) )
@TypeConverters(MinstrelTypeConverters::class) @TypeConverters(MinstrelTypeConverters::class)
@@ -92,3 +103,25 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun authSessionDao(): AuthSessionDao abstract fun authSessionDao(): AuthSessionDao
abstract fun diagnosticEventDao(): DiagnosticEventDao 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")
}
}
@@ -37,6 +37,7 @@ object DatabaseModule {
// launch, so users lose only the unsynced mutation queue // launch, so users lose only the unsynced mutation queue
// (acceptable while we're iterating). Replace with explicit // (acceptable while we're iterating). Replace with explicit
// Migration entries before the first tagged release. // Migration entries before the first tagged release.
.addMigrations(MIGRATION_8_9, MIGRATION_9_10)
.fallbackToDestructiveMigration(dropAllTables = true) .fallbackToDestructiveMigration(dropAllTables = true)
.build() .build()
@@ -46,4 +46,8 @@ interface AuthSessionDao {
/** Partial update: change only the per-device diagnostics opt-out. */ /** Partial update: change only the per-device diagnostics opt-out. */
@Query("UPDATE auth_session SET diagnosticsOptOut = :optOut WHERE id = 0") @Query("UPDATE auth_session SET diagnosticsOptOut = :optOut WHERE id = 0")
suspend fun setDiagnosticsOptOut(optOut: Boolean) 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?)
} }
@@ -31,4 +31,8 @@ interface CachedMutationDao {
@Query("DELETE FROM cached_mutations") @Query("DELETE FROM cached_mutations")
suspend fun clear() 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
} }
@@ -38,6 +38,27 @@ interface CachedTrackDao {
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertAll(rows: List<CachedTrackEntity>) suspend fun upsertAll(rows: List<CachedTrackEntity>)
/**
* ReplayGain values for [ids] (M464 #5000): the track's own from its row,
* the album's from its album row. A track not in the cache has no row.
*/
@Query(
"SELECT t.id AS id, t.trackGain AS trackGain, t.trackPeak AS trackPeak, " +
"a.albumGain AS albumGain, a.albumPeak AS albumPeak " +
"FROM cached_tracks t LEFT JOIN cached_albums a ON a.id = t.albumId " +
"WHERE t.id IN (:ids)",
)
suspend fun replayGains(ids: List<String>): List<CachedReplayGain>
@Query("DELETE FROM cached_tracks WHERE id IN (:ids)") @Query("DELETE FROM cached_tracks WHERE id IN (:ids)")
suspend fun deleteByIds(ids: List<String>) suspend fun deleteByIds(ids: List<String>)
} }
/** One row of [CachedTrackDao.replayGains]. */
data class CachedReplayGain(
val id: String,
val trackGain: Float?,
val trackPeak: Float?,
val albumGain: Float?,
val albumPeak: Float?,
)
@@ -43,4 +43,10 @@ data class AuthSessionEntity(
* choice lives here. Default false = honor the account flag. * choice lives here. Default false = honor the account flag.
*/ */
val diagnosticsOptOut: Boolean = false, 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,
) )
@@ -18,5 +18,8 @@ data class CachedAlbumEntity(
val releaseDate: String? = null, val releaseDate: String? = null,
val coverPath: String? = null, val coverPath: String? = null,
val mbid: String? = null, val mbid: String? = null,
// ReplayGain 2.0 album values (M464); null until every track is measured.
val albumGain: Float? = null,
val albumPeak: Float? = null,
val fetchedAt: Instant = Clock.System.now(), val fetchedAt: Instant = Clock.System.now(),
) )
@@ -26,5 +26,9 @@ data class CachedTrackEntity(
val fileFormat: String? = null, val fileFormat: String? = null,
val genre: String? = null, val genre: String? = null,
val missing: Boolean = false, val missing: Boolean = false,
// ReplayGain 2.0 track values (M464), kept so cached audio levels
// offline. Null until the server has measured the track.
val trackGain: Float? = null,
val trackPeak: Float? = null,
val fetchedAt: Instant = Clock.System.now(), val fetchedAt: Instant = Clock.System.now(),
) )
@@ -2,6 +2,7 @@ package com.fabledsword.minstrel.cache.mutations
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
@@ -41,6 +42,11 @@ object MutationKind {
// an undo collapses to the latest intent instead of replaying as two // an undo collapses to the latest intent instead of replaying as two
// opposed calls whose order decides the outcome. // opposed calls whose order decides the outcome.
const val SUGGESTION_SNOOZE_TOGGLE: String = "suggestion_snooze_toggle" 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 * This matches `feedback_offline_first_for_server_writes` — writes
* never go fire-and-forget. * never go fire-and-forget.
*/ */
@Suppress("TooManyFunctions") // one enqueue per mutation kind, like the replayer's dispatchers
@Singleton @Singleton
class MutationQueue @Inject constructor( class MutationQueue @Inject constructor(
private val dao: CachedMutationDao, 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( suspend fun enqueueRequestCancel(requestId: String): Long = insertUserDriven(
MutationKind.REQUEST_CANCEL, MutationKind.REQUEST_CANCEL,
json.encodeToString( json.encodeToString(
@@ -7,6 +7,7 @@ import com.fabledsword.minstrel.api.endpoints.DiscoverApi
import com.fabledsword.minstrel.api.endpoints.EventsApi import com.fabledsword.minstrel.api.endpoints.EventsApi
import com.fabledsword.minstrel.api.endpoints.FlagRequest import com.fabledsword.minstrel.api.endpoints.FlagRequest
import com.fabledsword.minstrel.api.endpoints.LikesApi 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.PlaybackErrorReportRequest
import com.fabledsword.minstrel.api.endpoints.PlaybackErrorsApi import com.fabledsword.minstrel.api.endpoints.PlaybackErrorsApi
import com.fabledsword.minstrel.api.endpoints.PlaylistsApi 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.cache.db.entities.CachedMutationEntity
import com.fabledsword.minstrel.di.ApplicationScope import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.likes.data.LikesRepository import com.fabledsword.minstrel.likes.data.LikesRepository
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
import com.fabledsword.minstrel.models.wire.CreateRequestBody import com.fabledsword.minstrel.models.wire.CreateRequestBody
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
@@ -79,6 +81,7 @@ class MutationReplayer @Inject constructor(
private val eventsApi: EventsApi = retrofit.create() private val eventsApi: EventsApi = retrofit.create()
private val requestsApi: RequestsApi = retrofit.create() private val requestsApi: RequestsApi = retrofit.create()
private val playbackErrorsApi: PlaybackErrorsApi = retrofit.create() private val playbackErrorsApi: PlaybackErrorsApi = retrofit.create()
private val meApi: MeApi = retrofit.create()
private val mutex = Mutex() private val mutex = Mutex()
@@ -166,6 +169,7 @@ class MutationReplayer @Inject constructor(
MutationKind.REQUEST_CANCEL -> dispatchRequestCancel(row.payload) MutationKind.REQUEST_CANCEL -> dispatchRequestCancel(row.payload)
MutationKind.PLAYBACK_ERROR_REPORT -> dispatchPlaybackErrorReport(row.payload) MutationKind.PLAYBACK_ERROR_REPORT -> dispatchPlaybackErrorReport(row.payload)
MutationKind.SUGGESTION_SNOOZE_TOGGLE -> dispatchSuggestionSnoozeToggle(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. // Unknown kind — drop so a stale schema entry can't wedge the queue.
else -> Outcome.DROP else -> Outcome.DROP
} }
@@ -279,6 +283,16 @@ class MutationReplayer @Inject constructor(
return Outcome.SENT 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 { private suspend fun dispatchPlaybackErrorReport(payload: String): Outcome {
val decoded = json.decodeFromString(PlaybackErrorReportPayload.serializer(), payload) val decoded = json.decodeFromString(PlaybackErrorReportPayload.serializer(), payload)
playbackErrorsApi.report( playbackErrorsApi.report(
@@ -303,7 +317,8 @@ class MutationReplayer @Inject constructor(
/** /**
* Row ids of desired-state toggles superseded by a later toggle for the same * 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 * 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. * 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 * 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) json.decodeFromString(SuggestionSnoozeTogglePayload.serializer(), row.payload)
}.getOrNull()?.let { "${row.kind}:${it.mbid}" } }.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 else -> null
} }
@@ -206,6 +206,8 @@ private fun SyncAlbumWire.toEntity(): CachedAlbumEntity = CachedAlbumEntity(
releaseDate = releaseDate, releaseDate = releaseDate,
coverPath = coverArtPath, coverPath = coverArtPath,
mbid = mbid, mbid = mbid,
albumGain = albumGain,
albumPeak = albumPeak,
) )
private fun SyncTrackWire.toEntity(): CachedTrackEntity = CachedTrackEntity( private fun SyncTrackWire.toEntity(): CachedTrackEntity = CachedTrackEntity(
@@ -220,4 +222,6 @@ private fun SyncTrackWire.toEntity(): CachedTrackEntity = CachedTrackEntity(
fileFormat = fileFormat, fileFormat = fileFormat,
genre = genre, genre = genre,
missing = missing, missing = missing,
trackGain = trackGain,
trackPeak = trackPeak,
) )
@@ -0,0 +1,18 @@
package com.fabledsword.minstrel.models.wire
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** One track's entry in `GET /api/tracks/replay-gain` (#4997). Decode-only. */
@Serializable
data class ReplayGainWire(
@SerialName("track_gain") val trackGain: Float? = null,
@SerialName("track_peak") val trackPeak: Float? = null,
@SerialName("album_gain") val albumGain: Float? = null,
@SerialName("album_peak") val albumPeak: Float? = null,
)
@Serializable
data class ReplayGainResponseWire(
val items: Map<String, ReplayGainWire> = emptyMap(),
)
@@ -28,6 +28,10 @@ data class SyncAlbumWire(
@SerialName("release_date") val releaseDate: String? = null, @SerialName("release_date") val releaseDate: String? = null,
@SerialName("cover_art_path") val coverArtPath: String? = null, @SerialName("cover_art_path") val coverArtPath: String? = null,
val mbid: String? = null, val mbid: String? = null,
// The album's ReplayGain 2.0 values (#4997): dB to -18 LUFS and a linear
// peak. Null until every track on the album is measured.
@SerialName("album_gain") val albumGain: Float? = null,
@SerialName("album_peak") val albumPeak: Float? = null,
) )
@Serializable @Serializable
@@ -51,6 +55,9 @@ data class SyncTrackWire(
// its tracks stay playable, which is the correct reading of "this server // its tracks stay playable, which is the correct reading of "this server
// has nothing to say about missing files". // has nothing to say about missing files".
val missing: Boolean = false, val missing: Boolean = false,
// The track's ReplayGain 2.0 values (#4997); null until it is measured.
@SerialName("track_gain") val trackGain: Float? = null,
@SerialName("track_peak") val trackPeak: Float? = null,
) )
/** /**
@@ -93,6 +93,8 @@ class MinstrelForwardingPlayer(
* one. Diagnostics-only; see [TransportObservation]. * one. Diagnostics-only; see [TransportObservation].
*/ */
val onTransport: (TransportObservation) -> Unit = {}, 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) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@@ -622,6 +624,7 @@ class MinstrelForwardingPlayer(
trackNumber = info.track, trackNumber = info.track,
) )
syncLocalCursorToRemote(sonosTrack = info.track, trackUri = info.trackUri) syncLocalCursorToRemote(sonosTrack = info.track, trackUri = info.trackUri)
events.onRendererTrack(info.track)
val transport = active.avTransport.getTransportInfo() val transport = active.avTransport.getTransportInfo()
when (transport.state) { when (transport.state) {
TransportState.PLAYING -> { TransportState.PLAYING -> {
@@ -12,8 +12,10 @@ import androidx.media3.session.SessionCommand
import com.fabledsword.minstrel.MainActivity import com.fabledsword.minstrel.MainActivity
import com.fabledsword.minstrel.likes.data.LikesRepository import com.fabledsword.minstrel.likes.data.LikesRepository
import com.fabledsword.minstrel.likes.data.LikesRepository.Companion.ENTITY_TRACK import com.fabledsword.minstrel.likes.data.LikesRepository.Companion.ENTITY_TRACK
import com.fabledsword.minstrel.settings.data.NormalizationRepository
import com.google.common.collect.ImmutableList import com.google.common.collect.ImmutableList
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -25,6 +27,7 @@ import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
/** /**
@@ -66,6 +69,8 @@ class MinstrelPlayerService : MediaSessionService() {
@Inject lateinit var likesRepository: LikesRepository @Inject lateinit var likesRepository: LikesRepository
@Inject lateinit var normalizationRepository: NormalizationRepository
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private var mediaSession: MediaSession? = null private var mediaSession: MediaSession? = null
@@ -82,6 +87,21 @@ class MinstrelPlayerService : MediaSessionService() {
.build() .build()
mediaSession = session mediaSession = session
serviceScope.launch { observeLikeState(session, player) } serviceScope.launch { observeLikeState(session, player) }
serviceScope.launch { refreshNormalization() }
}
// Takes up a leveling preference changed on another device (M464 #5000).
// Offline, the device's copy stands, which is the one playback reads.
private suspend fun refreshNormalization() {
try {
normalizationRepository.refresh()
} catch (e: CancellationException) {
throw e
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
Timber.d(e, "normalization refresh skipped")
}
} }
/** /**
@@ -14,6 +14,8 @@ import androidx.media3.session.MediaController
import androidx.media3.session.SessionToken import androidx.media3.session.SessionToken
import com.fabledsword.minstrel.di.ApplicationScope import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.models.TrackRef import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.player.gain.GainAudioProcessor
import com.fabledsword.minstrel.player.gain.ReplayGainStore
import com.fabledsword.minstrel.playlists.data.PlaylistsRepository import com.fabledsword.minstrel.playlists.data.PlaylistsRepository
import com.fabledsword.minstrel.playlists.data.toPlayableTrackRefs import com.fabledsword.minstrel.playlists.data.toPlayableTrackRefs
import com.fabledsword.minstrel.shared.resolveServerUrl import com.fabledsword.minstrel.shared.resolveServerUrl
@@ -69,6 +71,7 @@ class PlayerController @Inject constructor(
private val playerFactory: PlayerFactory, private val playerFactory: PlayerFactory,
private val activeUpnpHolder: com.fabledsword.minstrel.player.output.ActiveUpnpHolder, private val activeUpnpHolder: com.fabledsword.minstrel.player.output.ActiveUpnpHolder,
private val remoteState: RemotePlayerState, private val remoteState: RemotePlayerState,
private val replayGains: ReplayGainStore,
) { ) {
/** /**
@@ -277,6 +280,11 @@ class PlayerController @Inject constructor(
queueRefs = playable.tracks queueRefs = playable.tracks
val items = playable.tracks.map { it.toMediaItem(source) } val items = playable.tracks.map { it.toMediaItem(source) }
val startIndex = playable.initialIndex val startIndex = playable.initialIndex
// Gains for the first tracks, so the first one levels from its first
// sample rather than ramping in once the lookup lands.
replayGains.request(
playable.tracks.drop(startIndex).take(GAIN_PREFETCH).map { it.id },
)
// Drift #562 cold-boot resume calls this from a non-Main suspend // Drift #562 cold-boot resume calls this from a non-Main suspend
// context after awaitReady() unblocks (ResumeController launches // context after awaitReady() unblocks (ResumeController launches
// on Dispatchers.Default by the time it reaches us). MediaController // on Dispatchers.Default by the time it reaches us). MediaController
@@ -792,7 +800,13 @@ class PlayerController @Inject constructor(
// scrubber a real total even when the wrapped ExoPlayer is // scrubber a real total even when the wrapped ExoPlayer is
// paused under UPnP (it never probes a duration in that state). // paused under UPnP (it never probes a duration in that state).
if (durationSec > 0) setDurationMs(durationSec.toLong() * MS_PER_SECOND) if (durationSec > 0) setDurationMs(durationSec.toLong() * MS_PER_SECOND)
if (source != null) setExtras(sourceExtras(source)) // Album and track position let the gain processor tell an
// album played in order from a mix (M464 #5000).
trackNumber?.let { setTrackNumber(it) }
discNumber?.let { setDiscNumber(it) }
val extras = GainAudioProcessor.albumExtras(albumId)
if (source != null) extras.putAll(sourceExtras(source))
setExtras(extras)
// Point the notification / lock-screen art at the SAME album // Point the notification / lock-screen art at the SAME album
// cover the in-app surfaces use (TrackRef.coverUrl -> // cover the in-app surfaces use (TrackRef.coverUrl ->
// /api/albums/{id}/cover). Without this, Media3 falls back to // /api/albums/{id}/cover). Without this, Media3 falls back to
@@ -860,6 +874,7 @@ class PlayerController @Inject constructor(
const val MINSTREL_SOURCE_KEY: String = "minstrel_source" const val MINSTREL_SOURCE_KEY: String = "minstrel_source"
private const val MS_PER_SECOND = 1_000L private const val MS_PER_SECOND = 1_000L
private const val MAX_INTERPOLATION_DRIFT_MS = 5_000L private const val MAX_INTERPOLATION_DRIFT_MS = 5_000L
private const val GAIN_PREFETCH = 20
} }
} }
@@ -4,6 +4,7 @@ import android.content.Context
import androidx.media3.common.AudioAttributes import androidx.media3.common.AudioAttributes
import androidx.media3.common.C import androidx.media3.common.C
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.audio.AudioProcessor
import androidx.media3.common.util.BitmapLoader import androidx.media3.common.util.BitmapLoader
import androidx.media3.database.StandaloneDatabaseProvider import androidx.media3.database.StandaloneDatabaseProvider
import androidx.media3.datasource.DataSourceBitmapLoader import androidx.media3.datasource.DataSourceBitmapLoader
@@ -12,11 +13,18 @@ import androidx.media3.datasource.cache.CacheDataSource
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
import androidx.media3.datasource.cache.SimpleCache import androidx.media3.datasource.cache.SimpleCache
import androidx.media3.datasource.okhttp.OkHttpDataSource import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.audio.AudioSink
import androidx.media3.exoplayer.audio.DefaultAudioSink
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.session.CacheBitmapLoader import androidx.media3.session.CacheBitmapLoader
import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.cache.audiocache.CacheConfig import com.fabledsword.minstrel.cache.audiocache.CacheConfig
import com.fabledsword.minstrel.player.gain.GainAudioProcessor
import com.fabledsword.minstrel.player.gain.ReplayGainStore
import com.fabledsword.minstrel.player.output.ActiveUpnpHolder import com.fabledsword.minstrel.player.output.ActiveUpnpHolder
import com.fabledsword.minstrel.player.output.SonosQueueLoader
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
@@ -56,6 +64,9 @@ class PlayerFactory @Inject constructor(
private val activeUpnpHolder: ActiveUpnpHolder, private val activeUpnpHolder: ActiveUpnpHolder,
private val remoteState: RemotePlayerState, private val remoteState: RemotePlayerState,
private val serverHealth: com.fabledsword.minstrel.connectivity.NetworkStatusController, private val serverHealth: com.fabledsword.minstrel.connectivity.NetworkStatusController,
private val authStore: AuthStore,
private val replayGains: ReplayGainStore,
private val sonosQueue: SonosQueueLoader,
) { ) {
private val cacheDir: File = File(context.cacheDir, "audio_cache").apply { mkdirs() } private val cacheDir: File = File(context.cacheDir, "audio_cache").apply { mkdirs() }
@@ -120,6 +131,7 @@ class PlayerFactory @Inject constructor(
onStalled = { trackId -> stallEventsInternal.tryEmit(trackId) }, onStalled = { trackId -> stallEventsInternal.tryEmit(trackId) },
onQueueTruncated = { queueRepairInternal.tryEmit(Unit) }, onQueueTruncated = { queueRepairInternal.tryEmit(Unit) },
onTransport = { transportInternal.tryEmit(it) }, onTransport = { transportInternal.tryEmit(it) },
onRendererTrack = { sonosQueue.onRendererTrack(it) },
), ),
) )
} }
@@ -142,7 +154,28 @@ class PlayerFactory @Inject constructor(
val mediaSourceFactory = DefaultMediaSourceFactory(context) val mediaSourceFactory = DefaultMediaSourceFactory(context)
.setDataSourceFactory(cacheDataSource) .setDataSourceFactory(cacheDataSource)
return ExoPlayer.Builder(context) // Loudness normalization (M464 #5000) runs inside the audio sink so
// each track's gain starts on its first sample. Audio offload would
// bypass the sink's processors; ExoPlayer leaves it off unless asked
// (TrackSelectionParameters.audioOffloadPreferences), and nothing here
// asks, so leveling always applies.
val gainProcessor = GainAudioProcessor(
prefs = { authStore.normalization.value },
store = replayGains,
)
val renderersFactory = object : DefaultRenderersFactory(context) {
override fun buildAudioSink(
context: Context,
enableFloatOutput: Boolean,
enableAudioOutputPlaybackParams: Boolean,
): AudioSink = DefaultAudioSink.Builder(context)
.setEnableFloatOutput(enableFloatOutput)
.setEnableAudioOutputPlaybackParameters(enableAudioOutputPlaybackParams)
.setAudioProcessors(arrayOf<AudioProcessor>(gainProcessor))
.build()
}
val exo = ExoPlayer.Builder(context, renderersFactory)
.setMediaSourceFactory(mediaSourceFactory) .setMediaSourceFactory(mediaSourceFactory)
.setAudioAttributes( .setAudioAttributes(
AudioAttributes.Builder() AudioAttributes.Builder()
@@ -153,6 +186,17 @@ class PlayerFactory @Inject constructor(
) )
.setHandleAudioBecomingNoisy(true) .setHandleAudioBecomingNoisy(true)
.build() .build()
// The processor finds a track's neighbours in play order, which
// depends on shuffle mode.
gainProcessor.shuffleEnabled = exo.shuffleModeEnabled
exo.addListener(
object : Player.Listener {
override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) {
gainProcessor.shuffleEnabled = shuffleModeEnabled
}
},
)
return exo
} }
/** /**
@@ -19,6 +19,13 @@ import javax.inject.Singleton
class StreamTokenProvider @Inject constructor(retrofit: Retrofit) { class StreamTokenProvider @Inject constructor(retrofit: Retrofit) {
private val api: CastApi = retrofit.create() 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),
)
} }
@@ -0,0 +1,139 @@
package com.fabledsword.minstrel.player.gain
import android.os.Bundle
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.common.Timeline
import androidx.media3.common.audio.AudioProcessor
import androidx.media3.common.audio.BaseAudioProcessor
import com.fabledsword.minstrel.settings.data.NormalizationBoost
import com.fabledsword.minstrel.settings.data.NormalizationMode
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
import java.nio.ByteBuffer
/**
* Applies each track's loudness gain inside ExoPlayer's audio sink (M464
* #5000), so the level changes on the exact sample a track starts, gapless
* transitions included. `Player.setVolume` could do neither: it stops at 1,
* and set from a transition callback it lands about two seconds late
* because the next track is already buffered (androidx/media#418).
*
* Media3 1.11 flushes the sink's processors at every item boundary with the
* playlist [Timeline] and the new item's period, which is how this knows
* which track it is processing and who its neighbours are.
*
* Boosts above unity are held under -1 dBFS by a peak limiter when the user
* chose one. In headroom mode the gain already stops short of the track's
* true peak, and the final clamp only guards against a bad measurement.
*
* Runs on the playback thread. [prefs] and [store] are read per buffer, so a
* changed preference or a gain that arrives mid-track applies at once,
* through a short ramp rather than a step.
*/
class GainAudioProcessor(
private val prefs: () -> NormalizationPrefs,
private val store: ReplayGainStore,
) : BaseAudioProcessor() {
/** Mirrors the player's shuffle mode, so neighbours are found in play order. */
@Volatile var shuffleEnabled: Boolean = false
private var currentId: String? = null
private var asAlbum = false
private val stage = GainStage()
override fun onConfigure(inputAudioFormat: AudioProcessor.AudioFormat): AudioProcessor.AudioFormat =
when (inputAudioFormat.encoding) {
C.ENCODING_PCM_16BIT, C.ENCODING_PCM_FLOAT -> inputAudioFormat
else -> AudioProcessor.AudioFormat.NOT_SET
}
override fun onFlush(streamMetadata: AudioProcessor.StreamMetadata) {
identify(streamMetadata)
// A new stream starts at its own level: ramping in from the previous
// track's gain would swell or dip its first moments.
stage.reset(inputAudioFormat.sampleRate, inputAudioFormat.channelCount, targetGain())
}
override fun onReset() {
currentId = null
asAlbum = false
}
override fun queueInput(inputBuffer: ByteBuffer) {
val size = inputBuffer.remaining()
if (size == 0) return
val out = replaceOutputBuffer(size)
val p = prefs()
val target = targetGain(p)
val limiting = p.mode != NormalizationMode.OFF && p.boost == NormalizationBoost.LIMITER
if (stage.isUnity(target, limiting)) {
out.put(inputBuffer)
} else {
stage.process(
input = inputBuffer,
out = out,
channels = inputAudioFormat.channelCount,
isFloat = inputAudioFormat.encoding == C.ENCODING_PCM_FLOAT,
target = target,
limiting = limiting,
)
}
out.flip()
}
private fun targetGain(p: NormalizationPrefs = prefs()): Float {
val id = currentId ?: return 1f
return GainMath.dbToLinear(GainMath.gainDb(p, store.get(id), asAlbum))
}
// Which track this stream is, and whether it is playing as part of its
// album. Also asks the store for the gains of the tracks coming up, so
// each is known before it starts.
private fun identify(meta: AudioProcessor.StreamMetadata) {
currentId = null
asAlbum = false
val uid = meta.periodUid
if (uid == null || meta.timeline.isEmpty) return
val timeline = meta.timeline
val index = timeline.getPeriodByUid(uid, Timeline.Period()).windowIndex
val window = Timeline.Window()
fun itemAt(i: Int): MediaItem? =
if (i == C.INDEX_UNSET) null else timeline.getWindow(i, window).mediaItem
fun nextOf(i: Int) = timeline.getNextWindowIndex(i, Player.REPEAT_MODE_OFF, shuffleEnabled)
val cur = itemAt(index)
val prev = itemAt(timeline.getPreviousWindowIndex(index, Player.REPEAT_MODE_OFF, shuffleEnabled))
val next = itemAt(nextOf(index))
if (cur != null) {
currentId = cur.mediaId
asAlbum = GainMath.playingAsAlbum(prev?.albumPosition(), cur.albumPosition(), next?.albumPosition())
}
val upcoming = mutableListOf<String>()
var i = index
while (i != C.INDEX_UNSET && upcoming.size < LOOKAHEAD) {
itemAt(i)?.let { upcoming += it.mediaId }
i = nextOf(i)
}
store.request(upcoming)
}
companion object {
/** MediaMetadata extras key holding the track's album id. */
const val EXTRA_ALBUM_ID = "minstrel.album_id"
private const val LOOKAHEAD = 20
/** Puts what [albumPosition] reads into a MediaItem's metadata extras. */
fun albumExtras(albumId: String, into: Bundle = Bundle()): Bundle =
into.apply { putString(EXTRA_ALBUM_ID, albumId) }
}
}
private fun MediaItem.albumPosition(): AlbumPosition = AlbumPosition(
albumId = mediaMetadata.extras?.getString(GainAudioProcessor.EXTRA_ALBUM_ID),
discNumber = mediaMetadata.discNumber,
trackNumber = mediaMetadata.trackNumber,
)
@@ -0,0 +1,96 @@
package com.fabledsword.minstrel.player.gain
import com.fabledsword.minstrel.settings.data.NormalizationBoost
import com.fabledsword.minstrel.settings.data.NormalizationMode
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
import kotlin.math.log10
import kotlin.math.min
import kotlin.math.pow
/**
* One track's ReplayGain 2.0 values from the server (#4997): dB to the
* -18 LUFS reference, and linear true peaks. A null field has not been
* measured yet.
*/
data class ReplayGain(
val trackGain: Float?,
val trackPeak: Float?,
val albumGain: Float?,
val albumPeak: Float?,
) {
companion object {
val NONE = ReplayGain(null, null, null, null)
}
}
/** Where a queue item sits in its album, for the auto-mode album rule. */
data class AlbumPosition(val albumId: String?, val discNumber: Int?, val trackNumber: Int?)
/**
* Loudness-normalization math (M464 #5000). The same rules as the web
* player's `web/src/lib/player/gain.ts`, so a track levels the same on
* every device.
*/
object GainMath {
private const val REFERENCE_LUFS = -18
/** Headroom mode raises a quiet track only until its true peak reaches this. */
const val PEAK_CEILING_DBTP = -1f
/**
* No track is raised more than this, whatever its measurement says: a
* near-silent track would otherwise come out as amplified noise.
*/
const val MAX_BOOST_DB = 12f
private const val DISC_STRIDE = 1000
// Amplitude decibels: 20 dB per factor of ten.
private const val DB_PER_DECADE = 20f
/**
* The gain to apply, in dB. 0 when leveling is off or the track has not
* been measured: an unmeasured track plays as mastered.
*/
fun gainDb(prefs: NormalizationPrefs, g: ReplayGain?, asAlbum: Boolean): Float {
if (prefs.mode == NormalizationMode.OFF || g == null) return 0f
val wantAlbum = prefs.mode == NormalizationMode.ALBUM ||
(prefs.mode == NormalizationMode.AUTO && asAlbum)
// Album gain falls back to track gain while the album is still being
// measured; track gain never falls back to album gain.
val useAlbum = wantAlbum && g.albumGain != null
val gain = if (useAlbum) g.albumGain else g.trackGain
val peak = if (useAlbum) g.albumPeak else g.trackPeak
return gain?.let { leveled(prefs, it, peak) } ?: 0f
}
private fun leveled(prefs: NormalizationPrefs, gain: Float, peak: Float?): Float {
var db = gain + (prefs.targetLufs - REFERENCE_LUFS)
if (prefs.boost == NormalizationBoost.HEADROOM && peak != null && peak > 0f) {
db = min(db, PEAK_CEILING_DBTP - DB_PER_DECADE * log10(peak))
}
return min(db, MAX_BOOST_DB)
}
/**
* Whether the current item is being played as part of its album, in
* order: a neighbour in play order is from the same album and sits on
* the right side of it. That is when album gain keeps the album's own
* dynamics (a quiet intro stays quiet); anywhere else track gain levels
* the mix.
*/
fun playingAsAlbum(prev: AlbumPosition?, cur: AlbumPosition, next: AlbumPosition?): Boolean {
val curOrder = order(cur)
if (cur.albumId == null || curOrder == null) return false
val prevOrder = prev?.takeIf { it.albumId == cur.albumId }?.let { order(it) }
val nextOrder = next?.takeIf { it.albumId == cur.albumId }?.let { order(it) }
return (prevOrder != null && prevOrder < curOrder) || (nextOrder != null && nextOrder > curOrder)
}
// Disc-major track order. A track with no number has no place in the
// order and is never evidence of album play.
private fun order(p: AlbumPosition): Int? =
p.trackNumber?.let { (p.discNumber ?: 1) * DISC_STRIDE + it }
fun dbToLinear(db: Float): Float = 10f.pow(db / DB_PER_DECADE)
}
@@ -0,0 +1,82 @@
package com.fabledsword.minstrel.player.gain
import java.nio.ByteBuffer
import kotlin.math.abs
import kotlin.math.exp
import kotlin.math.max
import kotlin.math.roundToInt
/**
* The sample arithmetic of [GainAudioProcessor], apart from Media3 so it can
* be tested on the JVM: a gain that ramps toward its target, then an
* optional peak limiter, then a clamp to full scale. Interleaved 16-bit or
* float PCM in, the same format out.
*/
internal class GainStage {
private var gain = 1f
private var envelope = 0f
private var rampCoeff = 1f
private var releaseCoeff = 0f
private var frame = FloatArray(2)
/** Sets the sample rate and channel count, and starts the gain at [startGain]. */
fun reset(sampleRate: Int, channels: Int, startGain: Float) {
val rate = sampleRate.coerceAtLeast(1).toFloat()
rampCoeff = 1f - exp(-1f / (RAMP_SECONDS * rate))
releaseCoeff = exp(-1f / (RELEASE_SECONDS * rate))
if (frame.size < channels) frame = FloatArray(channels)
gain = startGain
envelope = 0f
}
/** True when processing would copy the input unchanged. */
fun isUnity(target: Float, limiting: Boolean): Boolean = gain == 1f && target == 1f && !limiting
/**
* Processes every whole frame of [input] into [out]. A trailing partial
* frame is dropped, as Media3's own processors do.
*/
@Suppress("LongParameterList") // the PCM layout is four facts; a holder type would only rename them
fun process(
input: ByteBuffer,
out: ByteBuffer,
channels: Int,
isFloat: Boolean,
target: Float,
limiting: Boolean,
) {
val bytesPerFrame = channels * if (isFloat) FLOAT_BYTES else PCM16_BYTES
val frames = input.remaining() / bytesPerFrame
repeat(frames) {
gain += (target - gain) * rampCoeff
var peak = 0f
for (c in 0 until channels) {
val s = (if (isFloat) input.float else input.short / PCM16_SCALE) * gain
frame[c] = s
peak = max(peak, abs(s))
}
var reduce = 1f
if (limiting) {
envelope = max(peak, envelope * releaseCoeff)
if (envelope > LIMIT_CEILING) reduce = LIMIT_CEILING / envelope
}
for (c in 0 until channels) {
val v = (frame[c] * reduce).coerceIn(-1f, 1f)
if (isFloat) out.putFloat(v) else out.putShort((v * PCM16_MAX).roundToInt().toShort())
}
}
input.position(input.limit())
}
companion object {
/** -1 dBFS, the ceiling the web player's limiter holds too. */
const val LIMIT_CEILING = 0.8913f
private const val RAMP_SECONDS = 0.05f
private const val RELEASE_SECONDS = 0.25f
private const val PCM16_SCALE = 32768f
private const val PCM16_MAX = 32767f
private const val PCM16_BYTES = 2
private const val FLOAT_BYTES = 4
}
}
@@ -0,0 +1,140 @@
package com.fabledsword.minstrel.player.gain
import com.fabledsword.minstrel.api.endpoints.ReplayGainApi
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.ServerHealth
import com.fabledsword.minstrel.di.ApplicationScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import retrofit2.Retrofit
import retrofit2.create
import timber.log.Timber
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton
/**
* The gains the player levels by (M464 #5000), looked up per track and
* held for the life of the process. The audio thread reads them with [get];
* [request] fills them in the background.
*
* Sources, most to least preferred:
* 1. the library cache, synced with the gains, so cached audio levels
* offline;
* 2. the server's replay-gain lookup, for a track not cached yet or
* measured since the last sync;
* 3. none: the track plays as mastered, and the player picks the gain up
* the moment it lands.
*/
@Singleton
class ReplayGainStore internal constructor(
private val scope: CoroutineScope,
private val trackDao: CachedTrackDao,
private val api: ReplayGainApi,
private val serverHealth: () -> ServerHealth,
private val clock: () -> Long,
) {
@Inject constructor(
@ApplicationScope scope: CoroutineScope,
trackDao: CachedTrackDao,
retrofit: Retrofit,
network: NetworkStatusController,
) : this(
scope = scope,
trackDao = trackDao,
api = retrofit.create(),
serverHealth = { network.state.value },
clock = System::currentTimeMillis,
)
private val gains = ConcurrentHashMap<String, ReplayGain>()
private val inflight: MutableSet<String> = ConcurrentHashMap.newKeySet()
// When the server last said it had nothing for a track. Not cached for
// good: the backfill may measure it minutes later.
private val missedAt = ConcurrentHashMap<String, Long>()
/** The track's gains, or null while they are unknown. Safe from any thread. */
fun get(trackId: String): ReplayGain? = gains[trackId]
/** Starts loading gains for any of [trackIds] not already known or loading. */
fun request(trackIds: Collection<String>) {
val now = clock()
val wanted = trackIds.filter { id ->
!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<String>) {
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<String>) {
val rows = try {
trackDao.replayGains(ids)
} catch (e: CancellationException) {
throw e
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
Timber.w(e, "replay gain: cache read failed")
emptyList()
}
for (row in rows) {
// A cached row without a track gain is not an answer: the server
// may have measured the track since this device last synced.
if (row.trackGain == null) continue
gains[row.id] = ReplayGain(row.trackGain, row.trackPeak, row.albumGain, row.albumPeak)
}
}
/** Asks the server about [batch]; false when it could not be asked. */
private suspend fun fromServer(batch: List<String>): Boolean {
val res = try {
withTimeoutOrNull(REQUEST_TIMEOUT_MS) { api.getReplayGain(batch.joinToString(",")) }
} catch (e: CancellationException) {
throw e
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
Timber.w(e, "replay gain: lookup failed")
null
} ?: return false
val now = clock()
for (id in batch) {
val w = res.items[id]
if (w == null) {
missedAt[id] = now
} else {
gains[id] = ReplayGain(w.trackGain, w.trackPeak, w.albumGain, w.albumPeak)
}
}
return true
}
private companion object {
const val MAX_IDS_PER_REQUEST = 200 // the endpoint's limit
const val REQUEST_TIMEOUT_MS = 10_000L
const val MISS_RETRY_MS = 5 * 60_000L
}
}
@@ -4,6 +4,8 @@ import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.models.TrackRef import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.player.RemotePlayerState import com.fabledsword.minstrel.player.RemotePlayerState
import com.fabledsword.minstrel.player.StreamTokenProvider 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.AVTransportClient
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
import com.fabledsword.minstrel.player.output.upnp.bareUdn 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: * 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 * every write here is a SOAP call that can fail individually, and until
* [verifyQueueLength] nothing ever read the result back. * [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 @Singleton
class SonosQueueLoader @Inject constructor( class SonosQueueLoader @Inject constructor(
@@ -32,6 +39,13 @@ class SonosQueueLoader @Inject constructor(
private val activeUpnpHolder: ActiveUpnpHolder, private val activeUpnpHolder: ActiveUpnpHolder,
private val remoteState: RemotePlayerState, 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<TrackRef> = emptyList()
// The renderer track the next one was last prerendered for.
@Volatile private var prerenderedAfter = 0
suspend fun load( suspend fun load(
transport: AVTransportClient, transport: AVTransportClient,
route: OutputRoute, route: OutputRoute,
@@ -46,8 +60,10 @@ class SonosQueueLoader @Inject constructor(
"UPnP select: add %d initial tracks (currentIndex=%d, totalQueue=%d)", "UPnP select: add %d initial tracks (currentIndex=%d, totalQueue=%d)",
initialBatch.size, currentIndex, queue.size, initialBatch.size, currentIndex, queue.size,
) )
initialBatch.forEachIndexed { idx, ref -> sent = queue
val token = streamTokens.mint(ref.id) prerenderedAfter = currentIndex + 1
initialBatch.indices.forEach { idx ->
val token = mint(queue, idx, prerender = idx == currentIndex)
transport.addURIToQueue( transport.addURIToQueue(
uri = token.url, uri = token.url,
mime = token.mime, mime = token.mime,
@@ -64,12 +80,12 @@ class SonosQueueLoader @Inject constructor(
Timber.w("UPnP select: Play") Timber.w("UPnP select: Play")
transport.play() transport.play()
Timber.w("UPnP select: initial done; backgrounding remainder") 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 // Verify even when there is no tail to append: the initial batch is
// sent the same way and can be dropped the same way. // sent the same way and can be dropped the same way.
scope.launch { scope.launch {
if (remaining.isNotEmpty()) { prerender(queue, currentIndex + 1)
extendQueueOnSonos(transport, route, remaining, initialEnd) if (initialEnd < queue.size) {
extendQueueOnSonos(transport, route, queue, initialEnd)
} }
verifyQueueLength(transport, route, queue) verifyQueueLength(transport, route, queue)
} }
@@ -84,15 +100,16 @@ class SonosQueueLoader @Inject constructor(
private suspend fun extendQueueOnSonos( private suspend fun extendQueueOnSonos(
transport: AVTransportClient, transport: AVTransportClient,
route: OutputRoute, route: OutputRoute,
tracks: List<TrackRef>, queue: List<TrackRef>,
startPosition: Int, startPosition: Int,
) { ) {
val count = queue.size - startPosition
Timber.w( Timber.w(
"UPnP extend: appending %d tracks starting at position %d", "UPnP extend: appending %d tracks starting at position %d",
tracks.size, startPosition + 1, count, startPosition + 1,
) )
val succeeded = appendTracksToQueue(transport, route, tracks, startPosition) val succeeded = appendTracksToQueue(transport, route, queue, startPosition)
Timber.w("UPnP extend: done (%d / %d appended)", succeeded, tracks.size) 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) Timber.w("UPnP verify: renderer holds %d tracks, queue intact", nrTracks)
return return
} }
val missing = fullQueue.drop(nrTracks)
Timber.w( Timber.w(
"UPnP verify: %s holds %d of %d tracks; appending %d missing (round %d)", "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) 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 * Tolerates individual AddURIToQueue failures — log and continue so some
* tracks loaded is better than zero tracks loaded — and stops early after * tracks loaded is better than zero tracks loaded — and stops early after
* [EXTEND_ABORT_AFTER_FAILURES] consecutive ones. * [EXTEND_ABORT_AFTER_FAILURES] consecutive ones.
@@ -155,20 +171,20 @@ class SonosQueueLoader @Inject constructor(
private suspend fun appendTracksToQueue( private suspend fun appendTracksToQueue(
transport: AVTransportClient, transport: AVTransportClient,
route: OutputRoute, route: OutputRoute,
tracks: List<TrackRef>, queue: List<TrackRef>,
startPosition: Int, startPosition: Int,
): Int { ): Int {
var consecutiveFailures = 0 var consecutiveFailures = 0
var succeeded = 0 var succeeded = 0
var aborted = false var aborted = false
for ((i, ref) in tracks.withIndex()) { for (i in 0 until queue.size - startPosition) {
if (aborted) break if (aborted) break
if (activeUpnpHolder.active.value?.routeId != route.id) { if (activeUpnpHolder.active.value?.routeId != route.id) {
Timber.w("UPnP extend: cancelled at offset %d (route changed)", i) Timber.w("UPnP extend: cancelled at offset %d (route changed)", i)
aborted = true aborted = true
} else { } else {
val outcome = runCatching { val outcome = runCatching {
val token = streamTokens.mint(ref.id) val token = mint(queue, startPosition + i)
transport.addURIToQueue( transport.addURIToQueue(
uri = token.url, uri = token.url,
mime = token.mime, mime = token.mime,
@@ -220,6 +236,7 @@ class SonosQueueLoader @Inject constructor(
newQueue: List<TrackRef>, newQueue: List<TrackRef>,
): Boolean { ): Boolean {
val newIds = newQueue.map { it.id } val newIds = newQueue.map { it.id }
sent = newQueue
if (oldIds == newIds) return true if (oldIds == newIds) return true
val prefixLen = commonPrefixLength(oldIds, newIds) val prefixLen = commonPrefixLength(oldIds, newIds)
val suffixLen = commonSuffixLength( val suffixLen = commonSuffixLength(
@@ -271,8 +288,7 @@ class SonosQueueLoader @Inject constructor(
prefixLen + 1, prefixLen + 1,
) )
for (i in 0 until addedCount) { for (i in 0 until addedCount) {
val ref = newQueue[prefixLen + i] val token = mint(newQueue, prefixLen + i)
val token = streamTokens.mint(ref.id)
transport.addURIToQueue( transport.addURIToQueue(
uri = token.url, uri = token.url,
mime = token.mime, 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<TrackRef>, 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<TrackRef>, index: Int, prerender: Boolean = false) =
streamTokens.mint(queue[index].id, asAlbum = playingAsAlbum(queue, index), prerender = prerender)
private fun commonPrefixLength(a: List<String>, b: List<String>): Int { private fun commonPrefixLength(a: List<String>, b: List<String>): Int {
val limit = minOf(a.size, b.size) val limit = minOf(a.size, b.size)
for (i in 0 until limit) { for (i in 0 until limit) {
@@ -299,18 +336,33 @@ class SonosQueueLoader @Inject constructor(
return limit 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<TrackRef>, 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 // Abort the append loop after this many consecutive AddURIToQueue
// failures; Sonos rate-limits burst adds and a wall of failures means // failures; Sonos rate-limits burst adds and a wall of failures means
// it has stopped accepting, not that the next one might land. // it has stopped accepting, not that the next one might land.
const val EXTEND_ABORT_AFTER_FAILURES = 3 private const val EXTEND_ABORT_AFTER_FAILURES = 3
const val EXTEND_THROTTLE_MS = 50L private const val EXTEND_THROTTLE_MS = 50L
// Verify/repair passes after a queue load. Two: one to catch the // 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 // common case (a rate-limit burst dropped a chunk), one to catch a
// repair that itself got rate-limited. Beyond that the renderer is // repair that itself got rate-limited. Beyond that the renderer is
// refusing for a reason retrying won't fix, and the stall watchdog // refusing for a reason retrying won't fix, and the stall watchdog
// becomes the backstop. // becomes the backstop.
const val VERIFY_ROUNDS = 2 private const val VERIFY_ROUNDS = 2
} }
} }
@@ -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<Int> = 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,
}
@@ -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<NormalizationPrefs> = 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)
}
}
}
@@ -41,6 +41,10 @@ data class AboutUiState(
// check must decide on. Held in state rather than read inline so a test // check must decide on. Held in state rather than read inline so a test
// can drive the comparison without a BuildConfig. // can drive the comparison without a BuildConfig.
val installedCode: Long = BuildConfig.VERSION_CODE.toLong(), 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 isChecking: Boolean = false,
val installStage: InstallStage = InstallStage.IDLE, val installStage: InstallStage = InstallStage.IDLE,
val installMessage: String? = null, val installMessage: String? = null,
@@ -67,7 +71,7 @@ class AboutCardViewModel @Inject constructor(
val state: StateFlow<AboutUiState> = internal.asStateFlow() val state: StateFlow<AboutUiState> = internal.asStateFlow()
fun checkForUpdates() { fun checkForUpdates() {
if (internal.value.isChecking) return if (internal.value.isChecking || !internal.value.selfUpdates) return
viewModelScope.launch { viewModelScope.launch {
internal.update { it.copy(isChecking = true, installMessage = null) } internal.update { it.copy(isChecking = true, installMessage = null) }
val installed = internal.value.installedVersion val installed = internal.value.installedVersion
@@ -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."
}
@@ -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<NormalizationPrefs> = 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) }
}
}
@@ -161,6 +161,7 @@ private fun SettingsList(
onToggle = onToggleDiagnostics, onToggle = onToggleDiagnostics,
) )
} }
NormalizationCard()
AppearanceCard(themeMode = themeMode, onPick = onPickTheme) AppearanceCard(themeMode = themeMode, onPick = onPickTheme)
StorageCard() StorageCard()
AboutCard() AboutCard()
@@ -382,6 +383,14 @@ private fun AboutCard(viewModel: AboutCardViewModel = hiltViewModel()) {
@Composable @Composable
private fun UpdateControls(state: AboutUiState, viewModel: AboutCardViewModel) { 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) UpdateCheckLine(result = state.result)
Button( Button(
onClick = viewModel::checkForUpdates, onClick = viewModel::checkForUpdates,
@@ -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 * restart re-shows it, which is acceptable nudging for v1 (matches
* Flutter). Server 404 / network errors stay silent. Constructed at * Flutter). Server 404 / network errors stay silent. Constructed at
* launch via the construct-the-singleton trick in `MinstrelApplication`. * 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 @Singleton
class UpdateBannerController @Inject constructor( class UpdateBannerController @Inject constructor(
@@ -45,6 +48,10 @@ class UpdateBannerController @Inject constructor(
}.stateIn(scope, SharingStarted.Eagerly, null) }.stateIn(scope, SharingStarted.Eagerly, null)
init { init {
if (!BuildConfig.DEBUG) startPolling()
}
private fun startPolling() {
scope.launch { scope.launch {
while (true) { while (true) {
runOnce() runOnce()
@@ -17,8 +17,12 @@
hostnames rather than CIDR ranges, and both sets of hosts above are unknowable 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 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 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 lives somewhere.
can distinguish a LAN server from a WAN one.
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 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 the in-app updater. An APK altered in transit and re-signed is rejected by the
@@ -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<CleartextToPublicHostException> {
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)
}
}
@@ -1,6 +1,8 @@
package com.fabledsword.minstrel.cache.mutations package com.fabledsword.minstrel.cache.mutations
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity 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 kotlinx.serialization.json.Json
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
@@ -129,4 +131,26 @@ class SupersededToggleIdsTest {
fun `an empty queue collapses nothing`() { fun `an empty queue collapses nothing`() {
assertTrue(supersededToggleIds(emptyList(), json).isEmpty()) 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))
}
} }
@@ -0,0 +1,111 @@
package com.fabledsword.minstrel.player.gain
import com.fabledsword.minstrel.settings.data.NormalizationBoost
import com.fabledsword.minstrel.settings.data.NormalizationMode
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
import org.junit.jupiter.api.Test
import kotlin.math.log10
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/** Mirrors web/src/lib/player/gain.test.ts: both players must level alike. */
class GainMathTest {
private fun prefs(
mode: NormalizationMode = NormalizationMode.AUTO,
target: Int = -18,
boost: NormalizationBoost = NormalizationBoost.HEADROOM,
) = NormalizationPrefs(mode = mode, targetLufs = target, boost = boost)
private val g = ReplayGain(trackGain = -6f, trackPeak = 1f, albumGain = -4f, albumPeak = 1f)
@Test
fun `off and unmeasured tracks play as mastered`() {
assertEquals(0f, GainMath.gainDb(prefs(mode = NormalizationMode.OFF), g, false))
assertEquals(0f, GainMath.gainDb(prefs(), null, false))
assertEquals(0f, GainMath.gainDb(prefs(), ReplayGain.NONE, false))
}
@Test
fun `mode picks the gain and auto follows album play`() {
assertEquals(-6f, GainMath.gainDb(prefs(mode = NormalizationMode.TRACK), g, true))
assertEquals(-4f, GainMath.gainDb(prefs(mode = NormalizationMode.ALBUM), g, false))
assertEquals(-4f, GainMath.gainDb(prefs(), g, true))
assertEquals(-6f, GainMath.gainDb(prefs(), g, false))
}
@Test
fun `album gain falls back to track gain until the album is measured`() {
val partial = ReplayGain(trackGain = -6f, trackPeak = 1f, albumGain = null, albumPeak = null)
assertEquals(-6f, GainMath.gainDb(prefs(mode = NormalizationMode.ALBUM), partial, true))
}
@Test
fun `a louder target raises every gain by the difference`() {
assertEquals(-2f, GainMath.gainDb(prefs(mode = NormalizationMode.TRACK, target = -14), g, false))
}
@Test
fun `headroom stops a boost 1 dB under the true peak and the limiter lets it through`() {
val quiet = ReplayGain(trackGain = 8f, trackPeak = 0.5f, albumGain = null, albumPeak = null)
val headroom = GainMath.gainDb(prefs(mode = NormalizationMode.TRACK), quiet, false)
assertEquals(-1f - 20f * log10(0.5f), headroom, 1e-4f)
val limited = GainMath.gainDb(
prefs(mode = NormalizationMode.TRACK, boost = NormalizationBoost.LIMITER),
quiet,
false,
)
assertEquals(8f, limited)
}
@Test
fun `no boost passes the cap`() {
val silent = ReplayGain(trackGain = 30f, trackPeak = 0.001f, albumGain = null, albumPeak = null)
val db = GainMath.gainDb(
prefs(mode = NormalizationMode.TRACK, boost = NormalizationBoost.LIMITER),
silent,
false,
)
assertEquals(GainMath.MAX_BOOST_DB, db)
}
@Test
fun `cuts are never limited by the peak`() {
val hot = ReplayGain(trackGain = -9f, trackPeak = 1.4f, albumGain = null, albumPeak = null)
assertEquals(-9f, GainMath.gainDb(prefs(mode = NormalizationMode.TRACK), hot, false))
}
private fun pos(album: String, track: Int?, disc: Int? = 1) = AlbumPosition(album, disc, track)
@Test
fun `in-order neighbours from the same album are album play`() {
assertTrue(GainMath.playingAsAlbum(null, pos("x", 1), pos("x", 2)))
assertTrue(GainMath.playingAsAlbum(pos("x", 1), pos("x", 2), pos("x", 3)))
assertTrue(GainMath.playingAsAlbum(pos("x", 2), pos("x", 3), null))
}
@Test
fun `a shuffled album, a mix and a lone track are not album play`() {
assertFalse(GainMath.playingAsAlbum(null, pos("x", 3), pos("x", 1)))
assertFalse(GainMath.playingAsAlbum(null, pos("x", 1), pos("y", 2)))
assertFalse(GainMath.playingAsAlbum(null, pos("x", 1), null))
}
@Test
fun `disc order counts`() {
assertTrue(GainMath.playingAsAlbum(pos("x", 12, disc = 1), pos("x", 1, disc = 2), null))
}
@Test
fun `a neighbour without a track number is not evidence of order`() {
assertFalse(GainMath.playingAsAlbum(pos("x", null), pos("x", 3), null))
assertFalse(GainMath.playingAsAlbum(null, pos("x", null), pos("x", null)))
}
@Test
fun `dbToLinear`() {
assertEquals(1f, GainMath.dbToLinear(0f))
assertEquals(0.1f, GainMath.dbToLinear(-20f), 1e-6f)
}
}
@@ -0,0 +1,73 @@
package com.fabledsword.minstrel.player.gain
import org.junit.jupiter.api.Test
import java.nio.ByteBuffer
import java.nio.ByteOrder
import kotlin.math.abs
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class GainStageTest {
private fun floats(vararg v: Float): ByteBuffer =
ByteBuffer.allocateDirect(v.size * 4).order(ByteOrder.nativeOrder()).apply {
v.forEach { putFloat(it) }
flip()
}
private fun shorts(vararg v: Short): ByteBuffer =
ByteBuffer.allocateDirect(v.size * 2).order(ByteOrder.nativeOrder()).apply {
v.forEach { putShort(it) }
flip()
}
private fun runFloat(stage: GainStage, input: ByteBuffer, target: Float, limiting: Boolean): FloatArray {
val out = ByteBuffer.allocateDirect(input.remaining()).order(ByteOrder.nativeOrder())
stage.process(input, out, channels = 2, isFloat = true, target = target, limiting = limiting)
out.flip()
return FloatArray(out.remaining() / 4) { out.float }
}
@Test
fun `a stream that starts at its gain is scaled from the first sample`() {
val stage = GainStage().apply { reset(sampleRate = 44_100, channels = 2, startGain = 0.5f) }
val out = runFloat(stage, floats(0.8f, -0.4f), target = 0.5f, limiting = false)
assertEquals(0.4f, out[0], 1e-6f)
assertEquals(-0.2f, out[1], 1e-6f)
}
@Test
fun `a changed target ramps rather than steps`() {
val stage = GainStage().apply { reset(sampleRate = 44_100, channels = 2, startGain = 1f) }
val n = 44_100 // one second of stereo frames
val input = floats(*FloatArray(n * 2) { 0.5f })
val out = runFloat(stage, input, target = 0.5f, limiting = false)
assertTrue(out[0] > 0.49f, "first frame barely moved: ${out[0]}")
assertEquals(0.25f, out.last(), 1e-3f)
}
@Test
fun `the limiter holds a boosted peak at -1 dBFS`() {
val stage = GainStage().apply { reset(sampleRate = 44_100, channels = 2, startGain = 4f) }
val out = runFloat(stage, floats(0.9f, -0.9f, 0.1f, 0.1f), target = 4f, limiting = true)
out.forEach { assertTrue(abs(it) <= GainStage.LIMIT_CEILING + 1e-6f, "sample $it over the ceiling") }
}
@Test
fun `without the limiter a boost is clamped to full scale, 16-bit included`() {
val stage = GainStage().apply { reset(sampleRate = 44_100, channels = 2, startGain = 4f) }
val input = shorts(20_000, -20_000)
val out = ByteBuffer.allocateDirect(4).order(ByteOrder.nativeOrder())
stage.process(input, out, channels = 2, isFloat = false, target = 4f, limiting = false)
out.flip()
assertEquals(Short.MAX_VALUE, out.short)
assertEquals((-Short.MAX_VALUE).toShort(), out.short)
}
@Test
fun `unity is a pass-through`() {
val stage = GainStage().apply { reset(sampleRate = 44_100, channels = 2, startGain = 1f) }
assertTrue(stage.isUnity(target = 1f, limiting = false))
assertTrue(!stage.isUnity(target = 1f, limiting = true))
}
}
@@ -0,0 +1,74 @@
package com.fabledsword.minstrel.player.gain
import com.fabledsword.minstrel.api.endpoints.ReplayGainApi
import com.fabledsword.minstrel.cache.db.dao.CachedReplayGain
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
import com.fabledsword.minstrel.connectivity.ServerHealth
import com.fabledsword.minstrel.models.wire.ReplayGainResponseWire
import com.fabledsword.minstrel.models.wire.ReplayGainWire
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import java.io.IOException
import kotlin.test.assertEquals
import kotlin.test.assertNull
/** The fallback ladder: the library cache, then the server, then nothing. */
class ReplayGainStoreTest {
private val dao: CachedTrackDao = mockk()
private val api: ReplayGainApi = mockk()
private var health = ServerHealth.Healthy
private fun store() = ReplayGainStore(
scope = TestScope(),
trackDao = dao,
api = api,
serverHealth = { health },
clock = { 0L },
)
@Test
fun `cached gains are used and only the rest are asked of the server`() = runTest {
coEvery { dao.replayGains(listOf("a", "b", "c")) } returns listOf(
CachedReplayGain("a", trackGain = -6f, trackPeak = 1f, albumGain = -4f, albumPeak = 1f),
// Cached but unmeasured at the last sync: the server may know more.
CachedReplayGain("b", trackGain = null, trackPeak = null, albumGain = null, albumPeak = null),
)
coEvery { api.getReplayGain("b,c") } returns ReplayGainResponseWire(
items = mapOf("b" to ReplayGainWire(trackGain = -3f, trackPeak = 0.9f)),
)
val s = store()
s.load(listOf("a", "b", "c"))
assertEquals(-4f, s.get("a")?.albumGain)
assertEquals(-3f, s.get("b")?.trackGain)
assertNull(s.get("c"), "a track the server has not measured stays unknown")
}
@Test
fun `offline, the cache is all there is`() = runTest {
health = ServerHealth.Offline
coEvery { dao.replayGains(any()) } returns emptyList()
val s = store()
s.load(listOf("a"))
assertNull(s.get("a"))
coVerify(exactly = 0) { api.getReplayGain(any()) }
}
@Test
fun `a failed lookup leaves the track unknown, to be asked again`() = runTest {
coEvery { dao.replayGains(any()) } returns emptyList()
coEvery { api.getReplayGain(any()) } throws IOException("reset")
val s = store()
s.load(listOf("a"))
assertNull(s.get("a"))
}
}
@@ -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<TrackRef>) = 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))))
}
}
@@ -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()) }
}
}
+1 -1
View File
@@ -21,7 +21,7 @@ kotlinx-serialization = "1.7.3"
kotlinx-coroutines = "1.9.0" kotlinx-coroutines = "1.9.0"
kotlinx-datetime = "0.6.1" kotlinx-datetime = "0.6.1"
kotlinx-serialization-converter = "1.0.0" kotlinx-serialization-converter = "1.0.0"
media3 = "1.10.1" media3 = "1.11.0"
mediarouter = "1.7.0" mediarouter = "1.7.0"
coil = "3.0.0-rc02" coil = "3.0.0-rc02"
palette = "1.0.0" palette = "1.0.0"
+1 -1
View File
@@ -13,6 +13,7 @@ require (
github.com/jackc/pgx/v5 v5.9.2 github.com/jackc/pgx/v5 v5.9.2
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
golang.org/x/crypto v0.51.0 golang.org/x/crypto v0.51.0
golang.org/x/sync v0.21.0
gopkg.in/yaml.v3 v3.0.1 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/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/rogpeppe/go-internal v1.14.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/sys v0.44.0 // indirect
golang.org/x/text v0.39.0 // indirect golang.org/x/text v0.39.0 // indirect
) )
+7 -1
View File
@@ -46,10 +46,15 @@ func (h *handlers) handleGetLoudnessCoverage(w http.ResponseWriter, r *http.Requ
type loudnessSettingsBody struct { type loudnessSettingsBody struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
BackfillConcurrency int32 `json:"backfill_concurrency"` BackfillConcurrency int32 `json:"backfill_concurrency"`
LeveledCacheMB int32 `json:"leveled_cache_mb"`
} }
func loudnessSettingsBodyOf(s library.LoudnessSettings) loudnessSettingsBody { 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. // 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{ saved, err := h.loudnessSettings.Set(r.Context(), library.LoudnessSettings{
Enabled: req.Enabled, Enabled: req.Enabled,
BackfillConcurrency: req.BackfillConcurrency, BackfillConcurrency: req.BackfillConcurrency,
LeveledCacheMB: req.LeveledCacheMB,
}) })
if err != nil { if err != nil {
if errors.Is(err, library.ErrLoudnessSettingOutOfRange) { if errors.Is(err, library.ErrLoudnessSettingOutOfRange) {
+22
View File
@@ -7,6 +7,7 @@ package api
import ( import (
"log/slog" "log/slog"
"math/rand" "math/rand"
"path/filepath"
"time" "time"
"github.com/go-chi/chi/v5" "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, reacqSettings: reacqSettings,
fingerprintSettings: fpSettings, fingerprintSettings: fpSettings,
loudnessSettings: loudSettings, loudnessSettings: loudSettings,
leveled: newLeveledRenderer(dataDir, loudSettings, logger),
librarySize: recommendation.NewLibrarySize(nil), librarySize: recommendation.NewLibrarySize(nil),
loginGuard: auth.NewLoginGuard(), loginGuard: auth.NewLoginGuard(),
setupToken: setupToken, 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 // audio format from the path. The {ext} param is consumed by chi
// and ignored by the handler (which keys off {id}). See task #610. // 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) 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) { api.Group(func(authed chi.Router) {
authed.Use(auth.RequireUser(pool, netSettings.Hops)) 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/password", h.handleChangePassword)
authed.Put("/me/profile", h.handleUpdateMyProfile) authed.Put("/me/profile", h.handleUpdateMyProfile)
authed.Put("/me/timezone", h.handlePutTimezone) 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.Post("/me/api-token", h.handleRegenerateMyAPIToken)
authed.Get("/me/subsonic-password", h.handleGetMySubsonicPassword) authed.Get("/me/subsonic-password", h.handleGetMySubsonicPassword)
authed.Post("/me/subsonic-password", h.handleGenerateMySubsonicPassword) authed.Post("/me/subsonic-password", h.handleGenerateMySubsonicPassword)
@@ -341,6 +348,10 @@ type handlers struct {
// loudnessSettings is the loudness analysis policy (M464 #4995), the same // loudnessSettings is the loudness analysis policy (M464 #4995), the same
// instance the loudness backfill reads. Nil serves the defaults. // instance the loudness backfill reads. Nil serves the defaults.
loudnessSettings *library.LoudnessSettingsService 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 // setupToken must accompany the first registration while no users exist
// (see auth.SetupToken). requireSetupToken is set by Mount, the only // (see auth.SetupToken). requireSetupToken is set by Mount, the only
// production constructor; tests that build handlers directly leave it // 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. // anything a client mints), which is the desired slice-1 default.
streamSecret []byte 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
}
+46 -10
View File
@@ -8,6 +8,7 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror" "git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
) )
const ( const (
@@ -19,6 +20,17 @@ const (
type castTokenRequest struct { type castTokenRequest struct {
TrackID string `json:"trackId"` TrackID string `json:"trackId"`
ExpSeconds int `json:"expSeconds,omitempty"` 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 { type castTokenResponse struct {
@@ -31,6 +43,10 @@ type castTokenResponse struct {
// `<res protocolInfo>` and `<dc:title>` without a follow-up round trip. // `<res protocolInfo>` and `<dc:title>` without a follow-up round trip.
MIME string `json:"mime"` MIME string `json:"mime"`
Title string `json:"title"` 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. // 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 // Part of the output-picker UPnP slice. See
// docs/superpowers/specs/2026-06-03-android-output-picker-upnp-design.md. // docs/superpowers/specs/2026-06-03-android-output-picker-upnp-design.md.
func (h *handlers) handleCastStreamToken(w http.ResponseWriter, r *http.Request) { func (h *handlers) handleCastStreamToken(w http.ResponseWriter, r *http.Request) {
if _, ok := requireUser(w, r); !ok { user, ok := requireUser(w, r)
if !ok {
return return
} }
var req castTokenRequest var req castTokenRequest
@@ -112,6 +129,27 @@ func (h *handlers) handleCastStreamToken(w http.ResponseWriter, r *http.Request)
expSec := clampExpSeconds(req.ExpSeconds) expSec := clampExpSeconds(req.ExpSeconds)
exp := time.Now().Add(time.Duration(expSec) * time.Second).Unix() exp := time.Now().Add(time.Duration(expSec) * time.Second).Unix()
token := SignStreamToken(h.streamSecret, req.TrackID, exp) 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 // Behind a TLS-terminating reverse proxy, r.TLS is nil even though
// the public-facing URL is https://. UPnP devices (Sonos especially) // 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 != "" { if h := r.Header.Get("X-Forwarded-Host"); h != "" {
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 // recognizable audio file. Without it, Sonos reports TrackDuration=0
// and seeks past 0s land "after the end" -> early track-skip. // 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{ writeJSON(w, http.StatusOK, castTokenResponse{
Token: token, Token: token,
Exp: exp, Exp: exp,
URL: url, URL: scheme + "://" + host + path,
MIME: mimeForFormat(track.FileFormat), MIME: mime,
Title: track.Title, Title: track.Title,
Leveled: leveled,
}) })
} }
+168
View File
@@ -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 "<id>|<exp>": 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)
}
+161
View File
@@ -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")
}
}
+51
View File
@@ -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)
}
+61
View File
@@ -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)
}
}
+4 -1
View File
@@ -552,6 +552,7 @@ SELECT a.id AS album_id,
SELECT file_path SELECT file_path
FROM tracks FROM tracks
WHERE album_id = a.id WHERE album_id = a.id
AND missing_since IS NULL
ORDER BY disc_number NULLS LAST, track_number NULLS LAST, id ORDER BY disc_number NULLS LAST, track_number NULLS LAST, id
LIMIT 1 LIMIT 1
) t ON true ) t ON true
@@ -569,7 +570,9 @@ type ListAlbumsMissingMbidWithTrackRow struct {
// One-shot MBID backfill: returns each album where mbid IS NULL alongside // 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 // 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) { func (q *Queries) ListAlbumsMissingMbidWithTrack(ctx context.Context, limit int32) ([]ListAlbumsMissingMbidWithTrackRow, error) {
rows, err := q.db.Query(ctx, listAlbumsMissingMbidWithTrack, limit) rows, err := q.db.Query(ctx, listAlbumsMissingMbidWithTrack, limit)
if err != nil { if err != nil {
+8 -4
View File
@@ -127,7 +127,7 @@ func (q *Queries) GetLoudnessCoverage(ctx context.Context, currentVersion int16)
} }
const getLoudnessSettings = `-- name: GetLoudnessSettings :one 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) { 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.Enabled,
&i.BackfillConcurrency, &i.BackfillConcurrency,
&i.UpdatedAt, &i.UpdatedAt,
&i.LeveledCacheMb,
) )
return i, err return i, err
} }
@@ -388,26 +389,29 @@ const updateLoudnessSettings = `-- name: UpdateLoudnessSettings :one
UPDATE loudness_settings UPDATE loudness_settings
SET enabled = $1, SET enabled = $1,
backfill_concurrency = $2, backfill_concurrency = $2,
leveled_cache_mb = $3,
updated_at = now() updated_at = now()
WHERE id = true WHERE id = true
RETURNING id, enabled, backfill_concurrency, updated_at RETURNING id, enabled, backfill_concurrency, updated_at, leveled_cache_mb
` `
type UpdateLoudnessSettingsParams struct { type UpdateLoudnessSettingsParams struct {
Enabled bool Enabled bool
BackfillConcurrency int32 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. // behind the service's own validation.
func (q *Queries) UpdateLoudnessSettings(ctx context.Context, arg UpdateLoudnessSettingsParams) (LoudnessSetting, error) { 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 var i LoudnessSetting
err := row.Scan( err := row.Scan(
&i.ID, &i.ID,
&i.Enabled, &i.Enabled,
&i.BackfillConcurrency, &i.BackfillConcurrency,
&i.UpdatedAt, &i.UpdatedAt,
&i.LeveledCacheMb,
) )
return i, err return i, err
} }
+9
View File
@@ -432,6 +432,7 @@ type LoudnessSetting struct {
Enabled bool Enabled bool
BackfillConcurrency int32 BackfillConcurrency int32
UpdatedAt pgtype.Timestamptz UpdatedAt pgtype.Timestamptz
LeveledCacheMb int32
} }
type MissingReacquisition struct { type MissingReacquisition struct {
@@ -784,6 +785,14 @@ type UserInvite struct {
RedeemedBy pgtype.UUID RedeemedBy pgtype.UUID
} }
type UserNormalizationPref struct {
UserID pgtype.UUID
Mode string
TargetLufs int16
Boost string
UpdatedAt pgtype.Timestamptz
}
type YouMightLikeAlbum struct { type YouMightLikeAlbum struct {
UserID pgtype.UUID UserID pgtype.UUID
AlbumID pgtype.UUID AlbumID pgtype.UUID
@@ -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
}
+3
View File
@@ -668,6 +668,7 @@ const listTracksMissingMbidWithPath = `-- name: ListTracksMissingMbidWithPath :m
SELECT id, file_path SELECT id, file_path
FROM tracks FROM tracks
WHERE mbid IS NULL WHERE mbid IS NULL
AND missing_since IS NULL
ORDER BY id ORDER BY id
LIMIT $1 LIMIT $1
` `
@@ -679,6 +680,8 @@ type ListTracksMissingMbidWithPathRow struct {
// Track recording-MBID backfill: tracks with NULL mbid that still have // Track recording-MBID backfill: tracks with NULL mbid that still have
// a file to re-read. $1 caps the batch (mirrors the album backfill). // 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) { func (q *Queries) ListTracksMissingMbidWithPath(ctx context.Context, limit int32) ([]ListTracksMissingMbidWithPathRow, error) {
rows, err := q.db.Query(ctx, listTracksMissingMbidWithPath, limit) rows, err := q.db.Query(ctx, listTracksMissingMbidWithPath, limit)
if err != nil { if err != nil {
@@ -0,0 +1 @@
DROP TABLE IF EXISTS user_normalization_prefs;
@@ -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()
);
@@ -0,0 +1,3 @@
ALTER TABLE loudness_settings
DROP CONSTRAINT loudness_settings_leveled_cache_range,
DROP COLUMN leveled_cache_mb;
@@ -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);
+4 -1
View File
@@ -145,7 +145,9 @@ UPDATE albums
-- name: ListAlbumsMissingMbidWithTrack :many -- name: ListAlbumsMissingMbidWithTrack :many
-- One-shot MBID backfill: returns each album where mbid IS NULL alongside -- 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 -- 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, SELECT a.id AS album_id,
a.artist_id AS artist_id, a.artist_id AS artist_id,
a.title AS title, a.title AS title,
@@ -155,6 +157,7 @@ SELECT a.id AS album_id,
SELECT file_path SELECT file_path
FROM tracks FROM tracks
WHERE album_id = a.id WHERE album_id = a.id
AND missing_since IS NULL
ORDER BY disc_number NULLS LAST, track_number NULLS LAST, id ORDER BY disc_number NULLS LAST, track_number NULLS LAST, id
LIMIT 1 LIMIT 1
) t ON true ) t ON true
+2 -1
View File
@@ -67,11 +67,12 @@ SELECT count(*)::bigint AS total,
SELECT * FROM loudness_settings WHERE id = true; SELECT * FROM loudness_settings WHERE id = true;
-- name: UpdateLoudnessSettings :one -- 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. -- behind the service's own validation.
UPDATE loudness_settings UPDATE loudness_settings
SET enabled = sqlc.arg(enabled), SET enabled = sqlc.arg(enabled),
backfill_concurrency = sqlc.arg(backfill_concurrency), backfill_concurrency = sqlc.arg(backfill_concurrency),
leveled_cache_mb = sqlc.arg(leveled_cache_mb),
updated_at = now() updated_at = now()
WHERE id = true WHERE id = true
RETURNING *; RETURNING *;
@@ -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;
+3
View File
@@ -26,9 +26,12 @@ RETURNING *;
-- name: ListTracksMissingMbidWithPath :many -- name: ListTracksMissingMbidWithPath :many
-- Track recording-MBID backfill: tracks with NULL mbid that still have -- Track recording-MBID backfill: tracks with NULL mbid that still have
-- a file to re-read. $1 caps the batch (mirrors the album backfill). -- 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 SELECT id, file_path
FROM tracks FROM tracks
WHERE mbid IS NULL WHERE mbid IS NULL
AND missing_since IS NULL
ORDER BY id ORDER BY id
LIMIT $1; LIMIT $1;
+4 -3
View File
@@ -90,9 +90,10 @@ var dataTables = []string{
"duplicate_group_members", // M400 "duplicate_group_members", // M400
"duplicate_groups", "duplicate_groups",
"duplicate_sweeps", "duplicate_sweeps",
"track_fingerprints", // M400 "track_fingerprints", // M400
"track_loudness", // M464 "track_loudness", // M464
"album_loudness", // M464 "album_loudness", // M464
"user_normalization_prefs", // M464
"tracks", "tracks",
"albums", "albums",
"artists", "artists",
+358
View File
@@ -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
}
+279
View File
@@ -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)
}
}
+17 -1
View File
@@ -22,17 +22,27 @@ type LoudnessSettings struct {
// values, so normalization keeps working for them. // values, so normalization keeps working for them.
Enabled bool Enabled bool
BackfillConcurrency int32 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 is set by the database; ignored by Set.
UpdatedAt time.Time 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. // database that cannot be read still analyzes the way a fresh install does.
var DefaultLoudnessSettings = LoudnessSettings{ var DefaultLoudnessSettings = LoudnessSettings{
Enabled: true, Enabled: true,
BackfillConcurrency: loudnessBackfillConcurrency, 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 // ErrLoudnessSettingOutOfRange is returned by Set for a value migration 0065's
// CHECK would reject, so the API answers 400 naming the field. // CHECK would reject, so the API answers 400 naming the field.
var ErrLoudnessSettingOutOfRange = errors.New("loudness setting out of range") 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{ row, err := dbq.New(s.pool).UpdateLoudnessSettings(ctx, dbq.UpdateLoudnessSettingsParams{
Enabled: in.Enabled, Enabled: in.Enabled,
BackfillConcurrency: in.BackfillConcurrency, BackfillConcurrency: in.BackfillConcurrency,
LeveledCacheMb: in.LeveledCacheMB,
}) })
if err != nil { if err != nil {
return LoudnessSettings{}, fmt.Errorf("loudness settings: save: %w", err) 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", return fmt.Errorf("%w: backfill_concurrency must be %d-%d",
ErrLoudnessSettingOutOfRange, minBackfillConcurrency, maxBackfillConcurrency) 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 return nil
} }
@@ -101,6 +116,7 @@ func loudnessSettingsFromRow(row dbq.LoudnessSetting) LoudnessSettings {
return LoudnessSettings{ return LoudnessSettings{
Enabled: row.Enabled, Enabled: row.Enabled,
BackfillConcurrency: row.BackfillConcurrency, BackfillConcurrency: row.BackfillConcurrency,
LeveledCacheMB: row.LeveledCacheMb,
UpdatedAt: row.UpdatedAt.Time, UpdatedAt: row.UpdatedAt.Time,
} }
} }
+19 -6
View File
@@ -233,14 +233,27 @@ func TestBackfillLoudnessResult_Add(t *testing.T) {
} }
func TestValidateLoudnessSettings(t *testing.T) { func TestValidateLoudnessSettings(t *testing.T) {
for _, n := range []int32{minBackfillConcurrency, maxBackfillConcurrency} { with := func(concurrency, cacheMB int32) LoudnessSettings {
if err := validateLoudnessSettings(LoudnessSettings{BackfillConcurrency: n}); err != nil { s := DefaultLoudnessSettings
t.Errorf("concurrency %d rejected: %v", n, err) 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} { for _, s := range []LoudnessSettings{
if err := validateLoudnessSettings(LoudnessSettings{BackfillConcurrency: n}); !errors.Is(err, ErrLoudnessSettingOutOfRange) { with(0, minLeveledCacheMB),
t.Errorf("concurrency %d: err = %v, want ErrLoudnessSettingOutOfRange", n, err) 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 var nilSvc *LoudnessSettingsService
+71
View File
@@ -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())
}
}
+84
View File
@@ -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
}
@@ -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)
}
}
+2
View File
@@ -386,6 +386,8 @@ export async function getLoudnessCoverage(): Promise<LoudnessCoverage> {
export type LoudnessSettings = { export type LoudnessSettings = {
enabled: boolean; enabled: boolean;
backfill_concurrency: number; backfill_concurrency: number;
/** Disk for the leveled copies rendered for Sonos/UPnP speakers (#5001). */
leveled_cache_mb: number;
}; };
export async function getLoudnessSettings(): Promise<LoudnessSettings> { export async function getLoudnessSettings(): Promise<LoudnessSettings> {
+62
View File
@@ -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<string, unknown>;
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<NormalizationPrefs> {
return api.get<NormalizationPrefs>('/api/me/normalization');
}
// The whole preference is sent every time; the server keeps the last write.
export function putNormalization(p: NormalizationPrefs): Promise<NormalizationPrefs> {
return api.put<NormalizationPrefs>('/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<string, ReplayGain> }> {
return apiFetch(`/api/tracks/replay-gain?ids=${ids.map(encodeURIComponent).join(',')}`, {
signal: AbortSignal.timeout(10_000)
}) as Promise<{ items: Record<string, ReplayGain> }>;
}
@@ -11,7 +11,8 @@
import { pushToast } from '$lib/stores/toast.svelte'; import { pushToast } from '$lib/stores/toast.svelte';
// Loudness analysis (#4995): the background measurement that loudness // 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<LoudnessSettings | null>(null); let saved = $state<LoudnessSettings | null>(null);
let form = $state<LoudnessSettings | null>(null); let form = $state<LoudnessSettings | null>(null);
@@ -26,6 +27,13 @@
form.backfill_concurrency >= 1 && form.backfill_concurrency >= 1 &&
form.backfill_concurrency <= 8 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() { async function load() {
try { try {
@@ -55,7 +63,7 @@
}); });
async function save() { async function save() {
if (!form || !concurrencyOk) return; if (!form || !valid) return;
saving = true; saving = true;
try { try {
saved = await updateLoudnessSettings(form); saved = await updateLoudnessSettings(form);
@@ -151,10 +159,26 @@
/> />
</label> </label>
{#if !concurrencyOk} <label class="block">
<p class="text-xs text-action-destructive" data-testid="settings-problems"> <span class="text-sm text-text-primary">Speaker cache (MB)</span>
Files analyzed at once must be a whole number from 1 to 8. <span class="block text-xs text-text-secondary">
</p> Leveled copies for Sonos and UPnP speakers. The least recently played go first.
</span>
<input
type="number"
min="256"
max="65536"
step="256"
bind:value={form.leveled_cache_mb}
class={inputClass}
/>
</label>
{#if !valid}
<div class="text-xs text-action-destructive" data-testid="settings-problems">
{#if !concurrencyOk}<p>Files analyzed at once must be a whole number from 1 to 8.</p>{/if}
{#if !cacheOk}<p>Speaker cache must be a whole number of MB from 256 to 65536.</p>{/if}
</div>
{/if} {/if}
<div class="flex justify-end"> <div class="flex justify-end">
@@ -163,7 +187,7 @@
class="rounded-md bg-action-secondary px-4 py-2 text-sm text-action-fg hover:opacity-90 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 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent
disabled:cursor-not-allowed disabled:opacity-50" disabled:cursor-not-allowed disabled:opacity-50"
disabled={!dirty || saving || !concurrencyOk} disabled={!dirty || saving || !valid}
onclick={save} onclick={save}
> >
{saving ? 'Saving…' : 'Save'} {saving ? 'Saving…' : 'Save'}
@@ -14,7 +14,7 @@ import LoudnessSettingsCard from './LoudnessSettingsCard.svelte';
import { getLoudnessCoverage, getLoudnessSettings, updateLoudnessSettings } from '$lib/api/admin'; import { getLoudnessCoverage, getLoudnessSettings, updateLoudnessSettings } from '$lib/api/admin';
import { pushToast } from '$lib/stores/toast.svelte'; 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 = { const coverage: LoudnessCoverage = {
total: 1200, total: 1200,
measured: 900, measured: 900,
@@ -68,7 +68,11 @@ describe('LoudnessSettingsCard', () => {
await fireEvent.click(saveButton()); await fireEvent.click(saveButton());
await waitFor(() => 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.')); await waitFor(() => expect(pushToast).toHaveBeenCalledWith('Loudness analysis settings saved.'));
// The gauge is read again after a save: switching analysis on or off changes it. // 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); 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 () => { test('a failed load offers a retry', async () => {
vi.mocked(getLoudnessSettings).mockRejectedValue(new Error('boom')); vi.mocked(getLoudnessSettings).mockRejectedValue(new Error('boom'));
vi.mocked(getLoudnessCoverage).mockResolvedValue(coverage); vi.mocked(getLoudnessCoverage).mockResolvedValue(coverage);
@@ -0,0 +1,106 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
NORMALIZATION_MODES,
NORMALIZATION_TARGETS,
type NormalizationPrefs
} from '$lib/api/normalization';
import {
normalization,
loadNormalization,
setNormalization
} from '$lib/stores/normalization.svelte';
import { pushToast } from '$lib/stores/toast.svelte';
// Volume leveling (M464 #4998). The choice is the user's, stored on the
// server, so it follows them to the Android app and to casts.
onMount(() => {
void loadNormalization();
});
const modeLabels: Record<string, string> = {
off: 'Off',
auto: 'Auto',
track: 'Track',
album: 'Album'
};
const modeHints: Record<string, string> = {
off: 'Tracks play at their mastered volume.',
auto: 'Album gain for whole albums in order, track gain otherwise.',
track: 'Every track at the same loudness.',
album: 'Albums keep their own quiet and loud tracks.'
};
async function change(patch: Partial<NormalizationPrefs>): Promise<void> {
try {
await setNormalization({ ...normalization.value, ...patch });
} catch {
pushToast('Could not save volume leveling.', 'error');
}
}
const pill = (on: boolean) =>
`flex cursor-pointer items-center rounded border border-border px-3 py-1.5 text-sm ${
on ? 'bg-action-secondary text-action-fg' : 'text-text-primary hover:bg-surface-hover'
}`;
</script>
<div class="space-y-3">
<fieldset class="space-y-2">
<legend class="block text-sm text-text-secondary">Volume leveling</legend>
<div class="flex flex-wrap gap-2">
{#each NORMALIZATION_MODES as m (m)}
<label class={pill(normalization.value.mode === m)}>
<input
type="radio"
name="normalization-mode"
value={m}
checked={normalization.value.mode === m}
onchange={() => change({ mode: m })}
class="sr-only"
/>
{modeLabels[m]}
</label>
{/each}
</div>
<p class="text-xs text-text-secondary">{modeHints[normalization.value.mode]}</p>
</fieldset>
{#if normalization.value.mode !== 'off'}
<fieldset class="space-y-2">
<legend class="block text-sm text-text-secondary">Target loudness</legend>
<div class="flex flex-wrap gap-2">
{#each NORMALIZATION_TARGETS as t (t)}
<label class={pill(normalization.value.target_lufs === t)}>
<input
type="radio"
name="normalization-target"
value={t}
checked={normalization.value.target_lufs === t}
onchange={() => change({ target_lufs: t })}
class="sr-only"
/>
<span class="tabular-nums">{t} LUFS</span>
</label>
{/each}
</div>
<p class="text-xs text-text-secondary">Higher is louder; −18 leaves the most headroom.</p>
</fieldset>
<label class="flex cursor-pointer items-center justify-between gap-3">
<span>
<span class="block text-sm text-text-primary">Boost quiet tracks fully</span>
<span class="block text-xs text-text-secondary">A limiter catches the peaks.</span>
</span>
<input
type="checkbox"
role="switch"
aria-checked={normalization.value.boost === 'limiter'}
checked={normalization.value.boost === 'limiter'}
onchange={(e) =>
change({ boost: (e.currentTarget as HTMLInputElement).checked ? 'limiter' : 'headroom' })}
class="h-4 w-4 accent-accent"
/>
</label>
{/if}
</div>
@@ -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<typeof import('$lib/api/normalization')>()),
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();
});
});
+87
View File
@@ -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> = {}): 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);
});
+68
View File
@@ -0,0 +1,68 @@
// Loudness normalization math for the web player (M464 #4999). Pure, so the
// rules are tested without an <audio> element or an AudioContext.
import type { NormalizationPrefs, ReplayGain } from '$lib/api/normalization';
import type { TrackRef } from '$lib/api/types';
// The server's gains are to the ReplayGain 2.0 reference.
const REFERENCE_LUFS = -18;
// Headroom mode raises a quiet track only until its true peak reaches this.
export const PEAK_CEILING_DBTP = -1;
// No track is raised more than this, whatever its measurement says: a
// near-silent track would otherwise come out as amplified noise.
export const MAX_BOOST_DB = 12;
/**
* Whether the track at `index` is being played as part of its album, in
* order: a neighbour in the queue 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.
*/
export function playingAsAlbum(queue: TrackRef[], index: number): boolean {
const cur = queue[index];
const curOrder = cur ? order(cur) : null;
if (!cur || curOrder === null) return false;
const inOrder = (n: TrackRef | undefined, before: boolean): boolean => {
if (!n || n.album_id !== cur.album_id) return false;
const o = order(n);
if (o === null) return false;
return before ? o < curOrder : o > curOrder;
};
return inOrder(queue[index - 1], true) || inOrder(queue[index + 1], false);
}
// Disc-major track order. A track with no number has no place in the order
// and is never evidence of album play.
function order(t: TrackRef): number | null {
if (t.track_number == null) return null;
return (t.disc_number ?? 1) * 1000 + t.track_number;
}
/**
* The gain to apply, in dB, for one track. 0 when leveling is off or the
* track has not been measured yet: an unmeasured track plays as mastered.
*/
export function gainDb(
prefs: NormalizationPrefs,
g: ReplayGain | undefined,
asAlbum: boolean
): number {
if (prefs.mode === 'off' || !g) return 0;
const 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.
const useAlbum = wantAlbum && g.album_gain !== undefined;
const gain = useAlbum ? g.album_gain : g.track_gain;
const peak = useAlbum ? g.album_peak : g.track_peak;
if (gain === undefined) return 0;
let db = gain + (prefs.target_lufs - REFERENCE_LUFS);
if (prefs.boost === 'headroom' && peak !== undefined && peak > 0) {
db = Math.min(db, PEAK_CEILING_DBTP - 20 * Math.log10(peak));
}
return Math.min(db, MAX_BOOST_DB);
}
export function dbToLinear(db: number): number {
return Math.pow(10, db / 20);
}
@@ -0,0 +1,62 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
import type { TrackRef } from '$lib/api/types';
import { makeTrack } from '$test-utils/fixtures/track';
vi.mock('$lib/auth/store.svelte', () => ({
user: {
get value() {
return { id: 'test-user' };
}
}
}));
const getReplayGain = vi.fn();
vi.mock('$lib/api/normalization', async (orig) => ({
...(await orig<typeof import('$lib/api/normalization')>()),
getReplayGain: (ids: string[]) => getReplayGain(ids)
}));
const { playQueue } = await import('./store.svelte');
const { applyGain, ensureGains, gainDbAt, hasGraph } = await import('./gainStage.svelte');
const track = (id: string, n: number): TrackRef =>
makeTrack({ id, album_id: 'alb', track_number: n, disc_number: 1 });
beforeEach(() => {
getReplayGain.mockReset();
});
describe('gain stage', () => {
test('fetches each queued gain once; an unmeasured track stays at 0 dB', async () => {
getReplayGain.mockResolvedValue({
items: { a: { track_gain: -6, track_peak: 1, album_gain: -4, album_peak: 1 } }
});
playQueue([track('a', 1), track('b', 2)]);
await ensureGains();
await ensureGains();
expect(getReplayGain).toHaveBeenCalledTimes(1);
expect(getReplayGain).toHaveBeenCalledWith(['a', 'b']);
// In album order, so auto mode takes the album gain.
expect(gainDbAt(0)).toBe(-4);
expect(gainDbAt(1)).toBe(0);
});
test('a failed fetch is asked again on the next call', async () => {
getReplayGain.mockRejectedValueOnce(new Error('offline'));
playQueue([track('c', 1)]);
await ensureGains();
expect(gainDbAt(0)).toBe(0);
getReplayGain.mockResolvedValueOnce({ items: { c: { track_gain: -3, track_peak: 1 } } });
await ensureGains();
expect(gainDbAt(0)).toBe(-3);
});
test('without the graph a cut scales the element and a boost plays at unity', () => {
expect(hasGraph()).toBe(false);
const el = { volume: 1 } as HTMLAudioElement;
applyGain(el, 0.8, -6);
expect(el.volume).toBeCloseTo(0.8 * 0.501, 3);
applyGain(el, 0.8, 6);
expect(el.volume).toBe(0.8);
});
});
+152
View File
@@ -0,0 +1,152 @@
// The web player's gain stage (M464 #4999). A cut is applied through the
// element's own volume. A boost needs Web Audio, because element.volume
// stops at 1: the element is routed through a GainNode and a
// DynamicsCompressor, which acts as a limiter when the user picked one.
//
// Routing is permanent once made (createMediaElementSource cannot be
// undone), and a context that is not running plays nothing. So the graph is
// built only when a track actually wants a boost, and only once a context
// has been confirmed running. Until then a boost plays at unity, which is
// what the track sounded like before leveling existed.
import { SvelteMap } from 'svelte/reactivity';
import { getReplayGain, type ReplayGain } from '$lib/api/normalization';
import { normalization } from '$lib/stores/normalization.svelte';
import { player } from './store.svelte';
import { dbToLinear, gainDb, playingAsAlbum } from './gain';
// Gains fetched so far; null means the server has none for that track yet.
// Kept for the page's life: a track measured later is picked up on reload.
const gains = new SvelteMap<string, ReplayGain | null>();
const inflight = new Set<string>();
// How far ahead of the current track gains are fetched, so a skip or the
// next track has its gain before it starts playing.
const LOOKAHEAD = 50;
const BATCH = 200; // the endpoint's limit
const stage = $state({ graph: false });
let ctx: AudioContext | null = null;
let gainNode: GainNode | null = null;
let limiter: DynamicsCompressorNode | null = null;
let building = false;
/** Fetches gains for the tracks from the current one through the lookahead. */
export async function ensureGains(): Promise<void> {
const ids = player.queue
.slice(Math.max(0, player.index), player.index + LOOKAHEAD)
.map((t) => t.id)
.filter((id) => !gains.has(id) && !inflight.has(id));
for (let i = 0; i < ids.length; i += BATCH) {
const batch = ids.slice(i, i + BATCH);
batch.forEach((id) => inflight.add(id));
try {
const res = await getReplayGain(batch);
for (const id of batch) gains.set(id, res.items[id] ?? null);
} catch {
// Left unset, so the next queue change asks again. Meanwhile the
// track plays as mastered.
} finally {
batch.forEach((id) => inflight.delete(id));
}
}
}
/** The gain for the queue item at `index`, in dB; 0 until its gain is known. */
export function gainDbAt(index: number): number {
const t = player.queue[index];
if (!t) return 0;
return gainDb(normalization.value, gains.get(t.id) ?? undefined, playingAsAlbum(player.queue, index));
}
/**
* Sets the element's volume to the user's volume times `fade`, leveled by
* `db`. Called from the layout's volume effect on every position update.
*/
export function applyGain(el: HTMLAudioElement, base: number, db: number): void {
const linear = dbToLinear(db);
if (!stage.graph || !ctx || !gainNode) {
el.volume = Math.min(1, base * Math.min(linear, 1));
return;
}
el.volume = base;
if (Math.abs(gainNode.gain.value - linear) > 1e-4) {
// A short ramp: a step in gain mid-signal is an audible click.
gainNode.gain.setTargetAtTime(linear, ctx.currentTime, 0.02);
}
configureLimiter();
}
export function hasGraph(): boolean {
return stage.graph;
}
// The limiter holds true peaks under -1 dBFS. In headroom mode the gain
// already keeps them there, so the compressor is set to pass audio through
// unchanged (ratio 1) rather than removed: the graph stays fixed.
function configureLimiter(): void {
if (!limiter || !ctx) return;
const on = normalization.value.boost === 'limiter';
const ratio = on ? 20 : 1;
if (limiter.ratio.value !== ratio) {
limiter.threshold.setValueAtTime(-1, ctx.currentTime);
limiter.knee.setValueAtTime(0, ctx.currentTime);
limiter.ratio.setValueAtTime(ratio, ctx.currentTime);
limiter.attack.setValueAtTime(0.003, ctx.currentTime);
limiter.release.setValueAtTime(0.25, ctx.currentTime);
}
}
/**
* Routes `el` through the gain graph, if that has not happened yet and a
* running AudioContext can be had. Browsers start a context suspended until
* the page has had a user gesture; the element is rerouted only after the
* context is confirmed running, so a refused context costs nothing and the
* next gesture tries again.
*/
export async function ensureGraph(el: HTMLAudioElement): Promise<void> {
if (stage.graph || building || isIOS()) return;
const AC: typeof AudioContext | undefined =
window.AudioContext ??
(window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!AC) return;
building = true;
const c = new AC();
try {
if (c.state !== 'running') await c.resume().catch(() => {});
if (c.state !== 'running') {
await c.close().catch(() => {});
return;
}
const source = c.createMediaElementSource(el);
const g = c.createGain();
const lim = c.createDynamicsCompressor();
source.connect(g).connect(lim).connect(c.destination);
ctx = c;
gainNode = g;
limiter = lim;
// Start at what the element was playing at, so the switch is silent.
g.gain.value = 1;
configureLimiter();
stage.graph = true;
} catch {
await c.close().catch(() => {});
} finally {
building = false;
}
}
// iOS suspends Web Audio when the screen locks, and a routed element goes
// silent with it, so iOS never gets the graph. It ignores element.volume
// too, so leveling there waits for the server's leveled stream (#5001).
function isIOS(): boolean {
const n = navigator;
return /iPad|iPhone|iPod/.test(n.userAgent) || (n.platform === 'MacIntel' && n.maxTouchPoints > 1);
}
/**
* Wakes the context if the browser suspended it (a backgrounded tab, an
* interruption). Routed audio plays only while the context runs.
*/
export function resumeGraph(): void {
if (ctx && ctx.state !== 'running') void ctx.resume().catch(() => {});
}
@@ -0,0 +1,66 @@
import * as storage from '$lib/util/safeLocalStorage';
import {
DEFAULT_NORMALIZATION,
getNormalization,
isNormalizationPrefs,
putNormalization,
type NormalizationPrefs
} from '$lib/api/normalization';
// The signed-in user's normalization preference. The server holds it; this
// keeps the last value seen in localStorage too, so the player levels the
// first track of a session before the fetch returns.
const STORAGE_KEY = 'minstrel:normalization';
function readCached(): NormalizationPrefs {
try {
const v: unknown = JSON.parse(storage.read(STORAGE_KEY) ?? 'null');
return isNormalizationPrefs(v) ? v : DEFAULT_NORMALIZATION;
} catch {
return DEFAULT_NORMALIZATION;
}
}
let _prefs = $state<NormalizationPrefs>(readCached());
// Bumped by every change made here. A load or a save that resolves after a
// newer change must not overwrite it.
let _generation = 0;
export const normalization = {
get value(): NormalizationPrefs {
return _prefs;
}
};
function commit(p: NormalizationPrefs): void {
_prefs = p;
storage.write(STORAGE_KEY, JSON.stringify(p));
}
/** Refreshes from the server; on failure the cached value stands. */
export async function loadNormalization(): Promise<void> {
const gen = _generation;
try {
const p = await getNormalization();
if (gen === _generation) commit(p);
} catch {
// Offline or signed out: keep what we had.
}
}
/**
* Applies [next] at once, then saves it. If the save fails the previous
* value is restored and the error rethrown for the caller to report.
*/
export async function setNormalization(next: NormalizationPrefs): Promise<void> {
const prev = _prefs;
const gen = ++_generation;
commit(next);
try {
const saved = await putNormalization(next);
if (gen === _generation) commit(saved);
} catch (err) {
if (gen === _generation) commit(prev);
throw err;
}
}
+41 -1
View File
@@ -28,6 +28,14 @@
import { applyMetaThemeColor } from '$lib/theme/applyMetaThemeColor.svelte'; import { applyMetaThemeColor } from '$lib/theme/applyMetaThemeColor.svelte';
import { audioLoader } from '$lib/player/audioLoader'; import { audioLoader } from '$lib/player/audioLoader';
import { attachStallRetry } from '$lib/player/stallRetry'; import { attachStallRetry } from '$lib/player/stallRetry';
import {
applyGain,
ensureGains,
ensureGraph,
gainDbAt,
resumeGraph
} from '$lib/player/gainStage.svelte';
import { untrack } from 'svelte';
let { children } = $props<{ children: import('svelte').Snippet }>(); let { children } = $props<{ children: import('svelte').Snippet }>();
let audioEl: HTMLAudioElement | undefined = $state(); let audioEl: HTMLAudioElement | undefined = $state();
@@ -95,8 +103,39 @@
// Per-position fade scalar: 1 normally, ramping in/out of the // Per-position fade scalar: 1 normally, ramping in/out of the
// crossfade window at track boundaries. Pure function of position // crossfade window at track boundaries. Pure function of position
// + duration + the operator's chosen crossfade duration. // + duration + the operator's chosen crossfade duration.
// Leveling (#4999) multiplies in last; the prefetch element is muted
// and never leveled.
const scalar = deriveFadeScalar(player.position, player.duration, player.crossfadeSec); const scalar = deriveFadeScalar(player.position, player.duration, player.crossfadeSec);
audioEl.volume = player.volume * scalar; applyGain(audioEl, player.volume * scalar, gainDbAt(player.index));
});
// Gains for the current track and those after it, fetched as the queue
// moves so each track's gain is in hand before it starts.
$effect(() => {
if (user.value === null) return;
player.queue;
player.index;
untrack(() => void ensureGains());
});
// A boost needs the Web Audio graph. It is built when the current or next
// track wants one, and from a user gesture if the browser refused a
// context without one.
const wantsBoost = $derived(gainDbAt(player.index) > 0 || gainDbAt(player.index + 1) > 0);
$effect(() => {
if (!audioEl || !wantsBoost) return;
const el = audioEl;
untrack(() => void ensureGraph(el));
const onGesture = () => {
resumeGraph();
void ensureGraph(el);
};
document.addEventListener('pointerdown', onGesture);
document.addEventListener('keydown', onGesture);
return () => {
document.removeEventListener('pointerdown', onGesture);
document.removeEventListener('keydown', onGesture);
};
}); });
$effect(() => { $effect(() => {
@@ -105,6 +144,7 @@
// emitted 'playing' yet, but we still need to call play() to get there. // emitted 'playing' yet, but we still need to call play() to get there.
const intent = player.state === 'playing' || player.state === 'loading'; const intent = player.state === 'playing' || player.state === 'loading';
if (intent) { if (intent) {
resumeGraph();
audioEl.play().catch(() => { /* interrupted / blocked — store gets the event */ }); audioEl.play().catch(() => { /* interrupted / blocked — store gets the event */ });
} else { } else {
audioEl.pause(); audioEl.pause();
+6 -1
View File
@@ -1,6 +1,7 @@
import type { LayoutLoad } from './$types'; import type { LayoutLoad } from './$types';
import { bootstrap, user } from '$lib/auth/store.svelte'; import { bootstrap, user } from '$lib/auth/store.svelte';
import { restoreQueue } from '$lib/player/store.svelte'; import { restoreQueue } from '$lib/player/store.svelte';
import { loadNormalization } from '$lib/stores/normalization.svelte';
export const ssr = false; // adapter-static fallback; we're SPA-only export const ssr = false; // adapter-static fallback; we're SPA-only
export const prerender = false; export const prerender = false;
@@ -8,6 +9,10 @@ export const prerender = false;
export const load: LayoutLoad = async () => { export const load: LayoutLoad = async () => {
await bootstrap(); await bootstrap();
const userId = user.value?.id; const userId = user.value?.id;
if (userId) restoreQueue(userId); if (userId) {
restoreQueue(userId);
// Not awaited: the cached preference levels playback until it lands.
void loadNormalization();
}
return {}; return {};
}; };
+3 -1
View File
@@ -50,7 +50,9 @@ vi.mock('$lib/api/admin', async () => {
refetchMissingCovers: vi.fn().mockResolvedValue({ started: true }), refetchMissingCovers: vi.fn().mockResolvedValue({ started: true }),
researchMissingArt: vi.fn().mockResolvedValue({ version: 1 }), researchMissingArt: vi.fn().mockResolvedValue({ version: 1 }),
// LoudnessSettingsCard, rendered by the page and tested on its own. // LoudnessSettingsCard, rendered by the page and tested on its own.
getLoudnessSettings: vi.fn().mockResolvedValue({ enabled: true, backfill_concurrency: 2 }), getLoudnessSettings: vi
.fn()
.mockResolvedValue({ enabled: true, backfill_concurrency: 2, leveled_cache_mb: 2048 }),
updateLoudnessSettings: vi.fn(), updateLoudnessSettings: vi.fn(),
getLoudnessCoverage: vi.fn().mockResolvedValue({ getLoudnessCoverage: vi.fn().mockResolvedValue({
total: 0, measured: 0, silent: 0, unreadable: 0, pending: 0, enabled: true total: 0, measured: 0, silent: 0, unreadable: 0, pending: 0, enabled: true
+3
View File
@@ -30,6 +30,7 @@
import MobileAppDownload from '$lib/components/MobileAppDownload.svelte'; import MobileAppDownload from '$lib/components/MobileAppDownload.svelte';
import ServerVersion from '$lib/components/ServerVersion.svelte'; import ServerVersion from '$lib/components/ServerVersion.svelte';
import ActiveSessions from '$lib/components/ActiveSessions.svelte'; import ActiveSessions from '$lib/components/ActiveSessions.svelte';
import NormalizationSetting from '$lib/components/NormalizationSetting.svelte';
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -341,6 +342,8 @@
0 = off · most albums sound best at 0. 0 = off · most albums sound best at 0.
</p> </p>
</div> </div>
<NormalizationSetting />
</section> </section>
<section class="space-y-4 rounded border border-border bg-surface p-4"> <section class="space-y-4 rounded border border-border bg-surface p-4">
@@ -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<typeof import('$lib/api/normalization')>();
return {
...mod,
getNormalization: vi.fn().mockResolvedValue(mod.DEFAULT_NORMALIZATION),
putNormalization: vi.fn()
};
});
vi.mock('$lib/api/metrics', () => ({ vi.mock('$lib/api/metrics', () => ({
createRecommendationMetricsQuery: () => ({ createRecommendationMetricsQuery: () => ({
subscribe: (run: (v: unknown) => void) => { subscribe: (run: (v: unknown) => void) => {
+10
View File
@@ -11,6 +11,16 @@ vi.mock('$lib/api/listenbrainz', () => ({
setListenBrainzEnabled: vi.fn() 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<typeof import('$lib/api/normalization')>();
return {
...mod,
getNormalization: vi.fn().mockResolvedValue(mod.DEFAULT_NORMALIZATION),
putNormalization: vi.fn()
};
});
vi.mock('$lib/api/me', () => ({ vi.mock('$lib/api/me', () => ({
updateProfile: vi.fn(), updateProfile: vi.fn(),
changePassword: vi.fn(), changePassword: vi.fn(),