M464 loudness leveling (steps 4–8), APK distribution and security baseline adoptions #136
@@ -272,8 +272,12 @@ jobs:
|
||||
# every integration package TRUNCATEs the one shared test DB;
|
||||
# concurrent package binaries → TRUNCATE deadlocks. Serialize
|
||||
# package execution (the documented local invocation too).
|
||||
# -timeout 20m: internal/api alone takes ~6.5 min under -race on
|
||||
# an idle runner, and a dev and a main run sharing the runner
|
||||
# pushed it past go test's default 10m (run 8368, 600.016s, the
|
||||
# running test 2s old — nothing hung).
|
||||
MINSTREL_DATABASE_URL="$MINSTREL_TEST_DATABASE_URL" go run ./cmd/minstrel migrate
|
||||
go test -p 1 -race ./...
|
||||
go test -p 1 -race -timeout 20m ./...
|
||||
|
||||
web:
|
||||
runs-on: go-ci
|
||||
@@ -352,22 +356,11 @@ jobs:
|
||||
- name: Unit tests
|
||||
run: ./gradlew testDebugUnitTest
|
||||
|
||||
- name: Assemble debug
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
run: ./gradlew assembleDebug
|
||||
|
||||
- name: Upload debug APK
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
# Stock action: it works on this forge since the runner moved to
|
||||
# gitea/runner 3.x, which edits upload-artifact's client-side GHES refusal
|
||||
# out of the action bundle (Scribe snippet #2271). Never @v3 — it reports
|
||||
# success while Gitea serves artifacts back only through the v4 API, and
|
||||
# it is what left 72 unreachable artifacts on this repo (Scribe 2270).
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: minstrel-android-debug-${{ github.sha }}
|
||||
path: android/app/build/outputs/apk/debug/app-debug.apk
|
||||
if-no-files-found: error
|
||||
# No debug APK is built or uploaded here. Main used to upload a
|
||||
# debug-signed app-debug.apk: a build signed by a key regenerated in
|
||||
# every container, which no install can update (family idea #5103,
|
||||
# practice 2). Phones get builds from android-release, signed with
|
||||
# the one release key, on dev and on tags.
|
||||
|
||||
# Known vulnerabilities in the Go code and the standard library it is built
|
||||
# with. Runs in the SAME image the Dockerfile's builder stage uses, so the
|
||||
@@ -530,6 +523,42 @@ jobs:
|
||||
-PMINSTREL_VERSION_NAME=${{ steps.ver.outputs.name }} \
|
||||
-PMINSTREL_VERSION_CODE=${{ steps.ver.outputs.code }}
|
||||
|
||||
# The APK every phone updates from must carry THE release key: Android
|
||||
# updates an app in place only when the signer matches, so an APK
|
||||
# signed by any other key (debug, a regenerated keystore, a swapped
|
||||
# secret) reaches no installed phone. The certificate's digest is
|
||||
# pinned below; it is public, not a secret. Gradle signs with the
|
||||
# release key or leaves the APK unsigned, and an unsigned build fails
|
||||
# here too, as there is no app-release.apk to verify (family idea
|
||||
# #5103, practice 3). apksigner, not keytool: keytool prints nothing
|
||||
# for a v2-only APK.
|
||||
#
|
||||
# Rotating the key on purpose means every install must be removed and
|
||||
# reinstalled; change the digest here in the same commit.
|
||||
- name: The APK carries the release key
|
||||
shell: bash
|
||||
env:
|
||||
# CN=Minstrel, O=FabledSword. Read from run 8446 (#5116).
|
||||
RELEASE_CERT_SHA256: 43d183307bc46b821789d90444a960b137f78f2166ff431efb2406d0fceaf612
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sdk="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}"
|
||||
signer="$(ls "$sdk"/build-tools/*/apksigner 2>/dev/null | sort -V | tail -1 || true)"
|
||||
test -n "$signer" || { echo "::error::no apksigner under '$sdk/build-tools'"; exit 1; }
|
||||
certs="$("$signer" verify --print-certs app/build/outputs/apk/release/app-release.apk)"
|
||||
printf '%s\n' "$certs" | grep -E '^Signer #[0-9]+ certificate (DN|SHA-256 digest)'
|
||||
# One signer, and it is ours. A second signer would be a lineage or
|
||||
# a mistake; either way not something to ship unexamined.
|
||||
digests="$(printf '%s\n' "$certs" | sed -n 's/^Signer #[0-9]* certificate SHA-256 digest: //p')"
|
||||
if [ "$digests" != "$RELEASE_CERT_SHA256" ]; then
|
||||
if printf '%s' "$certs" | grep -q 'CN=Android Debug'; then
|
||||
echo "::error::the release APK is signed with a debug key"
|
||||
else
|
||||
echo "::error::the release APK is not signed by the release key: got '${digests//$'\n'/ }', want ${RELEASE_CERT_SHA256}"
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Upload APK as workflow artifact
|
||||
# Stock action (snippet #2271) — never @v3, which uploads something Gitea
|
||||
# will never serve back. This is the producing half of a pair:
|
||||
|
||||
@@ -72,9 +72,13 @@ android {
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro",
|
||||
)
|
||||
// Signed with the release key or not at all. Falling back to the
|
||||
// debug key made a missing secret into a published APK that no
|
||||
// install could ever update (family idea #5103, practice 2). An
|
||||
// unsigned build installs nowhere, so the gap shows at once.
|
||||
signingConfig =
|
||||
if (System.getenv("ANDROID_KEYSTORE_PATH").isNullOrEmpty()) {
|
||||
signingConfigs.getByName("debug")
|
||||
null
|
||||
} else {
|
||||
signingConfigs.getByName("release")
|
||||
}
|
||||
|
||||
@@ -50,7 +50,14 @@ class BaseUrlInterceptor @Inject constructor(
|
||||
.port(baseUrl.port)
|
||||
.build()
|
||||
} ?: original.url
|
||||
return chain.proceed(original.newBuilder().url(rewritten).build())
|
||||
return chain.proceed(
|
||||
original.newBuilder()
|
||||
.url(rewritten)
|
||||
// Lets CleartextGuardInterceptor tell server requests from
|
||||
// external fetches once the placeholder host is gone.
|
||||
.tag(MinstrelServerRequest::class.java, MinstrelServerRequest)
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -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) {
|
||||
is HttpException -> fromHttp(t)
|
||||
is CleartextToPublicHostException -> messageFor("cleartext_public")
|
||||
is IOException -> messageFor("connection_refused")
|
||||
else -> TABLE.getValue("unknown")
|
||||
}
|
||||
@@ -101,6 +102,9 @@ object ErrorCopy {
|
||||
"mbid_required" to "An MBID is required for this lookup.",
|
||||
"system_playlist_readonly" to "System playlists can't be edited directly.",
|
||||
"connection_refused" to "Couldn't reach the server. Check the URL and try again.",
|
||||
"cleartext_public" to
|
||||
"This server is on the internet, so its URL must start with https://. " +
|
||||
"Plain http:// only works on your home network.",
|
||||
"lidarr_unreachable" to
|
||||
"Lidarr is unreachable right now. Try again, or check Admin → Integrations.",
|
||||
"lidarr_disabled" to "Lidarr integration is not enabled.",
|
||||
|
||||
@@ -70,6 +70,9 @@ object NetworkModule {
|
||||
.addInterceptor(auth)
|
||||
.addInterceptor(baseUrl)
|
||||
.addInterceptor(logging)
|
||||
// A network interceptor, so it sees the address the connection
|
||||
// really reached and runs before any request byte is written.
|
||||
.addNetworkInterceptor(CleartextGuardInterceptor())
|
||||
.connectTimeout(CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.readTimeout(READ_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
@@ -29,11 +29,20 @@ interface CastApi {
|
||||
* Request body. [expSeconds] is clamped server-side to [60, 86400];
|
||||
* the 21_600 default (6h) is long enough to play through any typical
|
||||
* track without re-minting mid-playback.
|
||||
*
|
||||
* [level] asks for the leveled stream (M464 #5001): the track rendered at
|
||||
* the user's loudness gain, which the server works out from their setting.
|
||||
* [asAlbum] says the track plays among its album in order, which picks
|
||||
* album gain in auto mode. [prerender] says the speaker will fetch it
|
||||
* soon, so the server renders it ahead.
|
||||
*/
|
||||
@Serializable
|
||||
data class StreamTokenRequest(
|
||||
val trackId: String,
|
||||
val expSeconds: Int = 21_600,
|
||||
val level: Boolean = false,
|
||||
val asAlbum: Boolean = false,
|
||||
val prerender: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -53,4 +62,6 @@ data class StreamTokenResponse(
|
||||
val url: String,
|
||||
val mime: String = "audio/mpeg",
|
||||
val title: String = "",
|
||||
/** [url] is the leveled stream; false when leveling is off or changes nothing. */
|
||||
val leveled: Boolean = false,
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.fabledsword.minstrel.api.endpoints
|
||||
import com.fabledsword.minstrel.models.wire.ListenBrainzStatusWire
|
||||
import com.fabledsword.minstrel.models.wire.MyProfileWire
|
||||
import com.fabledsword.minstrel.models.wire.SystemPlaylistsStatusWire
|
||||
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import retrofit2.http.Body
|
||||
@@ -59,6 +60,14 @@ interface MeApi {
|
||||
*/
|
||||
@PUT("api/me/listenbrainz")
|
||||
suspend fun setListenBrainz(@Body body: ListenBrainzPutBody): ListenBrainzStatusWire
|
||||
|
||||
/** The caller's loudness-normalization preference, or the defaults if never set. */
|
||||
@GET("api/me/normalization")
|
||||
suspend fun getNormalization(): NormalizationPrefs
|
||||
|
||||
/** Replaces the whole preference; returns what the server stored. */
|
||||
@PUT("api/me/normalization")
|
||||
suspend fun putNormalization(@Body body: NormalizationPrefs): NormalizationPrefs
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.entities.AuthSessionEntity
|
||||
import com.fabledsword.minstrel.di.ApplicationScope
|
||||
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.async
|
||||
@@ -77,6 +78,9 @@ class AuthStore @Inject constructor(
|
||||
private val diagnosticsOptOutState = MutableStateFlow(false)
|
||||
val diagnosticsOptOut: StateFlow<Boolean> = diagnosticsOptOutState.asStateFlow()
|
||||
|
||||
private val normalizationState = MutableStateFlow(NormalizationPrefs.DEFAULT)
|
||||
val normalization: StateFlow<NormalizationPrefs> = normalizationState.asStateFlow()
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
// Serialises every cookie persist with the one-time hydration, so a
|
||||
@@ -99,6 +103,7 @@ class AuthStore @Inject constructor(
|
||||
clientIdState.value = row?.clientId
|
||||
cacheSettingsState.value = decodeCacheSettings(row?.cacheSettingsJson)
|
||||
diagnosticsOptOutState.value = row?.diagnosticsOptOut ?: false
|
||||
normalizationState.value = decodeNormalization(row?.normalizationJson)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -110,6 +115,13 @@ class AuthStore @Inject constructor(
|
||||
}.getOrDefault(CacheSettings.DEFAULT)
|
||||
}
|
||||
|
||||
private fun decodeNormalization(raw: String?): NormalizationPrefs {
|
||||
if (raw.isNullOrEmpty()) return NormalizationPrefs.DEFAULT
|
||||
return runCatching {
|
||||
json.decodeFromString(NormalizationPrefs.serializer(), raw)
|
||||
}.getOrDefault(NormalizationPrefs.DEFAULT)
|
||||
}
|
||||
|
||||
/**
|
||||
* Suspends until the stored session cookie has been loaded into
|
||||
* [sessionCookie], or [HYDRATION_DEADLINE_MS] passes (rule 156: a wedged
|
||||
@@ -187,6 +199,12 @@ class AuthStore @Inject constructor(
|
||||
scope.launch { persistDiagnosticsOptOut(value) }
|
||||
}
|
||||
|
||||
fun setNormalization(value: NormalizationPrefs) {
|
||||
normalizationState.value = value
|
||||
val encoded = json.encodeToString(NormalizationPrefs.serializer(), value)
|
||||
scope.launch { persistNormalization(encoded) }
|
||||
}
|
||||
|
||||
private suspend fun persistLegacyCookie(value: String?) {
|
||||
if (dao.get() == null) {
|
||||
dao.upsert(currentEntity().copy(sessionCookie = value))
|
||||
@@ -243,6 +261,14 @@ class AuthStore @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun persistNormalization(json: String) {
|
||||
if (dao.get() == null) {
|
||||
dao.upsert(currentEntity().copy(normalizationJson = json))
|
||||
} else {
|
||||
dao.setNormalizationJson(json)
|
||||
}
|
||||
}
|
||||
|
||||
private fun currentEntity(): AuthSessionEntity = AuthSessionEntity(
|
||||
id = ROW_ID,
|
||||
// Never copied into the row: the cookie lives in the vault, and
|
||||
@@ -257,6 +283,10 @@ class AuthStore @Inject constructor(
|
||||
cacheSettingsState.value,
|
||||
),
|
||||
diagnosticsOptOut = diagnosticsOptOutState.value,
|
||||
normalizationJson = json.encodeToString(
|
||||
NormalizationPrefs.serializer(),
|
||||
normalizationState.value,
|
||||
),
|
||||
)
|
||||
|
||||
companion object {
|
||||
|
||||
+34
-1
@@ -3,6 +3,8 @@ package com.fabledsword.minstrel.cache.db
|
||||
import androidx.room.Database
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.TypeConverters
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import com.fabledsword.minstrel.cache.db.dao.AudioCacheIndexDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao
|
||||
@@ -65,13 +67,22 @@ import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
|
||||
AuthSessionEntity::class,
|
||||
DiagnosticEventEntity::class,
|
||||
],
|
||||
// v10: + cached_tracks.trackGain/trackPeak and cached_albums.albumGain/
|
||||
// albumPeak, the ReplayGain values the player levels by (M464 #5000).
|
||||
// MIGRATION_9_10 also rewinds the sync cursor, so the next sync re-sends
|
||||
// every row and an existing cache gains its values.
|
||||
// v9: + auth_session.normalizationJson, the loudness-normalization
|
||||
// preference (M464 #4998). The first schema step with an explicit
|
||||
// Migration (MIGRATION_8_9): a destructive rebuild would also wipe this
|
||||
// row — the server address and theme — and the queued offline writes,
|
||||
// which is too much to lose for one added column.
|
||||
// v8: + cached_tracks.missing, the server's missing-file mark (#2704),
|
||||
// so cache-first surfaces stop offering files that cannot stream.
|
||||
// v7: + diagnostic_events table (M9) and the diagnosticsOptOut column
|
||||
// on auth_session. Pre-v1 destructive fallback rebuilds on mismatch —
|
||||
// which is exactly right here: the next sync refills every row with the
|
||||
// new column populated, so there is nothing to migrate by hand.
|
||||
version = 8,
|
||||
version = 10,
|
||||
exportSchema = true,
|
||||
)
|
||||
@TypeConverters(MinstrelTypeConverters::class)
|
||||
@@ -92,3 +103,25 @@ abstract class AppDatabase : RoomDatabase() {
|
||||
abstract fun authSessionDao(): AuthSessionDao
|
||||
abstract fun diagnosticEventDao(): DiagnosticEventDao
|
||||
}
|
||||
|
||||
/** v8 → v9: add the nullable normalization preference column (#4998). */
|
||||
val MIGRATION_8_9: Migration = object : Migration(8, 9) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE auth_session ADD COLUMN normalizationJson TEXT")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v9 → v10: the gain columns (#5000). Rows synced before this carry no gains,
|
||||
* and the sync is incremental, so it would never re-send them: the cursor goes
|
||||
* back to 0 and the next sync is a full one, upserting every row in place.
|
||||
*/
|
||||
val MIGRATION_9_10: Migration = object : Migration(9, 10) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE cached_tracks ADD COLUMN trackGain REAL")
|
||||
db.execSQL("ALTER TABLE cached_tracks ADD COLUMN trackPeak REAL")
|
||||
db.execSQL("ALTER TABLE cached_albums ADD COLUMN albumGain REAL")
|
||||
db.execSQL("ALTER TABLE cached_albums ADD COLUMN albumPeak REAL")
|
||||
db.execSQL("UPDATE sync_metadata SET cursor = 0")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ object DatabaseModule {
|
||||
// launch, so users lose only the unsynced mutation queue
|
||||
// (acceptable while we're iterating). Replace with explicit
|
||||
// Migration entries before the first tagged release.
|
||||
.addMigrations(MIGRATION_8_9, MIGRATION_9_10)
|
||||
.fallbackToDestructiveMigration(dropAllTables = true)
|
||||
.build()
|
||||
|
||||
|
||||
+4
@@ -46,4 +46,8 @@ interface AuthSessionDao {
|
||||
/** Partial update: change only the per-device diagnostics opt-out. */
|
||||
@Query("UPDATE auth_session SET diagnosticsOptOut = :optOut WHERE id = 0")
|
||||
suspend fun setDiagnosticsOptOut(optOut: Boolean)
|
||||
|
||||
/** Partial update: change only the serialized normalization preference. */
|
||||
@Query("UPDATE auth_session SET normalizationJson = :json WHERE id = 0")
|
||||
suspend fun setNormalizationJson(json: String?)
|
||||
}
|
||||
|
||||
+4
@@ -31,4 +31,8 @@ interface CachedMutationDao {
|
||||
|
||||
@Query("DELETE FROM cached_mutations")
|
||||
suspend fun clear()
|
||||
|
||||
/** Whether a write of [kind] is still waiting to be replayed. */
|
||||
@Query("SELECT EXISTS(SELECT 1 FROM cached_mutations WHERE kind = :kind)")
|
||||
suspend fun hasPending(kind: String): Boolean
|
||||
}
|
||||
|
||||
+21
@@ -38,6 +38,27 @@ interface CachedTrackDao {
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
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)")
|
||||
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?,
|
||||
)
|
||||
|
||||
+6
@@ -43,4 +43,10 @@ data class AuthSessionEntity(
|
||||
* choice lives here. Default false = honor the account flag.
|
||||
*/
|
||||
val diagnosticsOptOut: Boolean = false,
|
||||
/**
|
||||
* JSON-encoded NormalizationPrefs (settings/data), the last value seen
|
||||
* from the server or set here (M464 #4998). Null = never fetched; the
|
||||
* defaults apply. Kept so offline playback still levels.
|
||||
*/
|
||||
val normalizationJson: String? = null,
|
||||
)
|
||||
|
||||
+3
@@ -18,5 +18,8 @@ data class CachedAlbumEntity(
|
||||
val releaseDate: String? = null,
|
||||
val coverPath: String? = null,
|
||||
val mbid: String? = null,
|
||||
// ReplayGain 2.0 album values (M464); null until every track is measured.
|
||||
val albumGain: Float? = null,
|
||||
val albumPeak: Float? = null,
|
||||
val fetchedAt: Instant = Clock.System.now(),
|
||||
)
|
||||
|
||||
+4
@@ -26,5 +26,9 @@ data class CachedTrackEntity(
|
||||
val fileFormat: String? = null,
|
||||
val genre: String? = null,
|
||||
val missing: Boolean = false,
|
||||
// ReplayGain 2.0 track values (M464), kept so cached audio levels
|
||||
// offline. Null until the server has measured the track.
|
||||
val trackGain: Float? = null,
|
||||
val trackPeak: Float? = null,
|
||||
val fetchedAt: Instant = Clock.System.now(),
|
||||
)
|
||||
|
||||
+13
@@ -2,6 +2,7 @@ package com.fabledsword.minstrel.cache.mutations
|
||||
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
|
||||
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
|
||||
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
@@ -41,6 +42,11 @@ object MutationKind {
|
||||
// an undo collapses to the latest intent instead of replaying as two
|
||||
// opposed calls whose order decides the outcome.
|
||||
const val SUGGESTION_SNOOZE_TOGGLE: String = "suggestion_snooze_toggle"
|
||||
|
||||
// M464 #4998 loudness-normalization preference. The payload is the whole
|
||||
// preference, a target state like the toggles above, so queued changes
|
||||
// collapse to the last one and an older one can never be replayed last.
|
||||
const val NORMALIZATION_SET: String = "normalization_set"
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -85,6 +91,7 @@ data class RequestCreatePayload(
|
||||
* This matches `feedback_offline_first_for_server_writes` — writes
|
||||
* never go fire-and-forget.
|
||||
*/
|
||||
@Suppress("TooManyFunctions") // one enqueue per mutation kind, like the replayer's dispatchers
|
||||
@Singleton
|
||||
class MutationQueue @Inject constructor(
|
||||
private val dao: CachedMutationDao,
|
||||
@@ -177,6 +184,12 @@ class MutationQueue @Inject constructor(
|
||||
),
|
||||
)
|
||||
|
||||
/** Queues the user's whole normalization preference for replay. */
|
||||
suspend fun enqueueNormalizationSet(prefs: NormalizationPrefs): Long = insertUserDriven(
|
||||
MutationKind.NORMALIZATION_SET,
|
||||
json.encodeToString(NormalizationPrefs.serializer(), prefs),
|
||||
)
|
||||
|
||||
suspend fun enqueueRequestCancel(requestId: String): Long = insertUserDriven(
|
||||
MutationKind.REQUEST_CANCEL,
|
||||
json.encodeToString(
|
||||
|
||||
+21
-1
@@ -7,6 +7,7 @@ import com.fabledsword.minstrel.api.endpoints.DiscoverApi
|
||||
import com.fabledsword.minstrel.api.endpoints.EventsApi
|
||||
import com.fabledsword.minstrel.api.endpoints.FlagRequest
|
||||
import com.fabledsword.minstrel.api.endpoints.LikesApi
|
||||
import com.fabledsword.minstrel.api.endpoints.MeApi
|
||||
import com.fabledsword.minstrel.api.endpoints.PlaybackErrorReportRequest
|
||||
import com.fabledsword.minstrel.api.endpoints.PlaybackErrorsApi
|
||||
import com.fabledsword.minstrel.api.endpoints.PlaylistsApi
|
||||
@@ -22,6 +23,7 @@ import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
|
||||
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
|
||||
import com.fabledsword.minstrel.di.ApplicationScope
|
||||
import com.fabledsword.minstrel.likes.data.LikesRepository
|
||||
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
|
||||
import com.fabledsword.minstrel.models.wire.CreateRequestBody
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
@@ -79,6 +81,7 @@ class MutationReplayer @Inject constructor(
|
||||
private val eventsApi: EventsApi = retrofit.create()
|
||||
private val requestsApi: RequestsApi = retrofit.create()
|
||||
private val playbackErrorsApi: PlaybackErrorsApi = retrofit.create()
|
||||
private val meApi: MeApi = retrofit.create()
|
||||
|
||||
private val mutex = Mutex()
|
||||
|
||||
@@ -166,6 +169,7 @@ class MutationReplayer @Inject constructor(
|
||||
MutationKind.REQUEST_CANCEL -> dispatchRequestCancel(row.payload)
|
||||
MutationKind.PLAYBACK_ERROR_REPORT -> dispatchPlaybackErrorReport(row.payload)
|
||||
MutationKind.SUGGESTION_SNOOZE_TOGGLE -> dispatchSuggestionSnoozeToggle(row.payload)
|
||||
MutationKind.NORMALIZATION_SET -> dispatchNormalizationSet(row.payload)
|
||||
// Unknown kind — drop so a stale schema entry can't wedge the queue.
|
||||
else -> Outcome.DROP
|
||||
}
|
||||
@@ -279,6 +283,16 @@ class MutationReplayer @Inject constructor(
|
||||
return Outcome.SENT
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the queued normalization preference. The device already shows
|
||||
* it, so the server's echo is not written back: a change made since the
|
||||
* row was queued would be a newer row, and the collapse keeps only that.
|
||||
*/
|
||||
private suspend fun dispatchNormalizationSet(payload: String): Outcome {
|
||||
meApi.putNormalization(json.decodeFromString(NormalizationPrefs.serializer(), payload))
|
||||
return Outcome.SENT
|
||||
}
|
||||
|
||||
private suspend fun dispatchPlaybackErrorReport(payload: String): Outcome {
|
||||
val decoded = json.decodeFromString(PlaybackErrorReportPayload.serializer(), payload)
|
||||
playbackErrorsApi.report(
|
||||
@@ -303,7 +317,8 @@ class MutationReplayer @Inject constructor(
|
||||
/**
|
||||
* Row ids of desired-state toggles superseded by a later toggle for the same
|
||||
* entity. Applies to every kind whose payload encodes a TARGET state rather
|
||||
* than an action — like-toggles and suggestion snoozes (#2374) — because
|
||||
* than an action — like-toggles, suggestion snoozes (#2374) and the
|
||||
* normalization preference (#4998) — because
|
||||
* replaying a stale one last would invert the final state.
|
||||
*
|
||||
* Top-level and pure so it can be unit-tested without standing up a Retrofit
|
||||
@@ -340,5 +355,10 @@ private fun toggleKeyOf(row: CachedMutationEntity, json: Json): String? = when (
|
||||
json.decodeFromString(SuggestionSnoozeTogglePayload.serializer(), row.payload)
|
||||
}.getOrNull()?.let { "${row.kind}:${it.mbid}" }
|
||||
|
||||
// One preference per user, so every normalization row shares one key.
|
||||
MutationKind.NORMALIZATION_SET -> runCatching {
|
||||
json.decodeFromString(NormalizationPrefs.serializer(), row.payload)
|
||||
}.getOrNull()?.let { row.kind }
|
||||
|
||||
else -> null
|
||||
}
|
||||
|
||||
+4
@@ -206,6 +206,8 @@ private fun SyncAlbumWire.toEntity(): CachedAlbumEntity = CachedAlbumEntity(
|
||||
releaseDate = releaseDate,
|
||||
coverPath = coverArtPath,
|
||||
mbid = mbid,
|
||||
albumGain = albumGain,
|
||||
albumPeak = albumPeak,
|
||||
)
|
||||
|
||||
private fun SyncTrackWire.toEntity(): CachedTrackEntity = CachedTrackEntity(
|
||||
@@ -220,4 +222,6 @@ private fun SyncTrackWire.toEntity(): CachedTrackEntity = CachedTrackEntity(
|
||||
fileFormat = fileFormat,
|
||||
genre = genre,
|
||||
missing = missing,
|
||||
trackGain = trackGain,
|
||||
trackPeak = trackPeak,
|
||||
)
|
||||
|
||||
@@ -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("cover_art_path") val coverArtPath: String? = null,
|
||||
val mbid: String? = null,
|
||||
// The album's ReplayGain 2.0 values (#4997): dB to -18 LUFS and a linear
|
||||
// peak. Null until every track on the album is measured.
|
||||
@SerialName("album_gain") val albumGain: Float? = null,
|
||||
@SerialName("album_peak") val albumPeak: Float? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -51,6 +55,9 @@ data class SyncTrackWire(
|
||||
// its tracks stay playable, which is the correct reading of "this server
|
||||
// has nothing to say about missing files".
|
||||
val missing: Boolean = false,
|
||||
// The track's ReplayGain 2.0 values (#4997); null until it is measured.
|
||||
@SerialName("track_gain") val trackGain: Float? = null,
|
||||
@SerialName("track_peak") val trackPeak: Float? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -93,6 +93,8 @@ class MinstrelForwardingPlayer(
|
||||
* one. Diagnostics-only; see [TransportObservation].
|
||||
*/
|
||||
val onTransport: (TransportObservation) -> Unit = {},
|
||||
/** The renderer's 1-based queue position, every poll. */
|
||||
val onRendererTrack: (trackNumber: Int) -> Unit = {},
|
||||
)
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
@@ -622,6 +624,7 @@ class MinstrelForwardingPlayer(
|
||||
trackNumber = info.track,
|
||||
)
|
||||
syncLocalCursorToRemote(sonosTrack = info.track, trackUri = info.trackUri)
|
||||
events.onRendererTrack(info.track)
|
||||
val transport = active.avTransport.getTransportInfo()
|
||||
when (transport.state) {
|
||||
TransportState.PLAYING -> {
|
||||
|
||||
@@ -12,8 +12,10 @@ import androidx.media3.session.SessionCommand
|
||||
import com.fabledsword.minstrel.MainActivity
|
||||
import com.fabledsword.minstrel.likes.data.LikesRepository
|
||||
import com.fabledsword.minstrel.likes.data.LikesRepository.Companion.ENTITY_TRACK
|
||||
import com.fabledsword.minstrel.settings.data.NormalizationRepository
|
||||
import com.google.common.collect.ImmutableList
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
@@ -25,6 +27,7 @@ import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
@@ -66,6 +69,8 @@ class MinstrelPlayerService : MediaSessionService() {
|
||||
|
||||
@Inject lateinit var likesRepository: LikesRepository
|
||||
|
||||
@Inject lateinit var normalizationRepository: NormalizationRepository
|
||||
|
||||
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||
|
||||
private var mediaSession: MediaSession? = null
|
||||
@@ -82,6 +87,21 @@ class MinstrelPlayerService : MediaSessionService() {
|
||||
.build()
|
||||
mediaSession = session
|
||||
serviceScope.launch { observeLikeState(session, player) }
|
||||
serviceScope.launch { refreshNormalization() }
|
||||
}
|
||||
|
||||
// Takes up a leveling preference changed on another device (M464 #5000).
|
||||
// Offline, the device's copy stands, which is the one playback reads.
|
||||
private suspend fun refreshNormalization() {
|
||||
try {
|
||||
normalizationRepository.refresh()
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||
) {
|
||||
Timber.d(e, "normalization refresh skipped")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,6 +14,8 @@ import androidx.media3.session.MediaController
|
||||
import androidx.media3.session.SessionToken
|
||||
import com.fabledsword.minstrel.di.ApplicationScope
|
||||
import com.fabledsword.minstrel.models.TrackRef
|
||||
import com.fabledsword.minstrel.player.gain.GainAudioProcessor
|
||||
import com.fabledsword.minstrel.player.gain.ReplayGainStore
|
||||
import com.fabledsword.minstrel.playlists.data.PlaylistsRepository
|
||||
import com.fabledsword.minstrel.playlists.data.toPlayableTrackRefs
|
||||
import com.fabledsword.minstrel.shared.resolveServerUrl
|
||||
@@ -69,6 +71,7 @@ class PlayerController @Inject constructor(
|
||||
private val playerFactory: PlayerFactory,
|
||||
private val activeUpnpHolder: com.fabledsword.minstrel.player.output.ActiveUpnpHolder,
|
||||
private val remoteState: RemotePlayerState,
|
||||
private val replayGains: ReplayGainStore,
|
||||
) {
|
||||
|
||||
/**
|
||||
@@ -277,6 +280,11 @@ class PlayerController @Inject constructor(
|
||||
queueRefs = playable.tracks
|
||||
val items = playable.tracks.map { it.toMediaItem(source) }
|
||||
val startIndex = playable.initialIndex
|
||||
// Gains for the first tracks, so the first one levels from its first
|
||||
// sample rather than ramping in once the lookup lands.
|
||||
replayGains.request(
|
||||
playable.tracks.drop(startIndex).take(GAIN_PREFETCH).map { it.id },
|
||||
)
|
||||
// Drift #562 cold-boot resume calls this from a non-Main suspend
|
||||
// context after awaitReady() unblocks (ResumeController launches
|
||||
// on Dispatchers.Default by the time it reaches us). MediaController
|
||||
@@ -792,7 +800,13 @@ class PlayerController @Inject constructor(
|
||||
// scrubber a real total even when the wrapped ExoPlayer is
|
||||
// paused under UPnP (it never probes a duration in that state).
|
||||
if (durationSec > 0) setDurationMs(durationSec.toLong() * MS_PER_SECOND)
|
||||
if (source != null) setExtras(sourceExtras(source))
|
||||
// Album and track position let the gain processor tell an
|
||||
// album played in order from a mix (M464 #5000).
|
||||
trackNumber?.let { setTrackNumber(it) }
|
||||
discNumber?.let { setDiscNumber(it) }
|
||||
val extras = GainAudioProcessor.albumExtras(albumId)
|
||||
if (source != null) extras.putAll(sourceExtras(source))
|
||||
setExtras(extras)
|
||||
// Point the notification / lock-screen art at the SAME album
|
||||
// cover the in-app surfaces use (TrackRef.coverUrl ->
|
||||
// /api/albums/{id}/cover). Without this, Media3 falls back to
|
||||
@@ -860,6 +874,7 @@ class PlayerController @Inject constructor(
|
||||
const val MINSTREL_SOURCE_KEY: String = "minstrel_source"
|
||||
private const val MS_PER_SECOND = 1_000L
|
||||
private const val MAX_INTERPOLATION_DRIFT_MS = 5_000L
|
||||
private const val GAIN_PREFETCH = 20
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.content.Context
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.audio.AudioProcessor
|
||||
import androidx.media3.common.util.BitmapLoader
|
||||
import androidx.media3.database.StandaloneDatabaseProvider
|
||||
import androidx.media3.datasource.DataSourceBitmapLoader
|
||||
@@ -12,11 +13,18 @@ import androidx.media3.datasource.cache.CacheDataSource
|
||||
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
|
||||
import androidx.media3.datasource.cache.SimpleCache
|
||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||
import androidx.media3.exoplayer.DefaultRenderersFactory
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.audio.AudioSink
|
||||
import androidx.media3.exoplayer.audio.DefaultAudioSink
|
||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||
import androidx.media3.session.CacheBitmapLoader
|
||||
import com.fabledsword.minstrel.auth.AuthStore
|
||||
import com.fabledsword.minstrel.cache.audiocache.CacheConfig
|
||||
import com.fabledsword.minstrel.player.gain.GainAudioProcessor
|
||||
import com.fabledsword.minstrel.player.gain.ReplayGainStore
|
||||
import com.fabledsword.minstrel.player.output.ActiveUpnpHolder
|
||||
import com.fabledsword.minstrel.player.output.SonosQueueLoader
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
@@ -56,6 +64,9 @@ class PlayerFactory @Inject constructor(
|
||||
private val activeUpnpHolder: ActiveUpnpHolder,
|
||||
private val remoteState: RemotePlayerState,
|
||||
private val serverHealth: com.fabledsword.minstrel.connectivity.NetworkStatusController,
|
||||
private val authStore: AuthStore,
|
||||
private val replayGains: ReplayGainStore,
|
||||
private val sonosQueue: SonosQueueLoader,
|
||||
) {
|
||||
private val cacheDir: File = File(context.cacheDir, "audio_cache").apply { mkdirs() }
|
||||
|
||||
@@ -120,6 +131,7 @@ class PlayerFactory @Inject constructor(
|
||||
onStalled = { trackId -> stallEventsInternal.tryEmit(trackId) },
|
||||
onQueueTruncated = { queueRepairInternal.tryEmit(Unit) },
|
||||
onTransport = { transportInternal.tryEmit(it) },
|
||||
onRendererTrack = { sonosQueue.onRendererTrack(it) },
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -142,7 +154,28 @@ class PlayerFactory @Inject constructor(
|
||||
val mediaSourceFactory = DefaultMediaSourceFactory(context)
|
||||
.setDataSourceFactory(cacheDataSource)
|
||||
|
||||
return ExoPlayer.Builder(context)
|
||||
// Loudness normalization (M464 #5000) runs inside the audio sink so
|
||||
// each track's gain starts on its first sample. Audio offload would
|
||||
// bypass the sink's processors; ExoPlayer leaves it off unless asked
|
||||
// (TrackSelectionParameters.audioOffloadPreferences), and nothing here
|
||||
// asks, so leveling always applies.
|
||||
val gainProcessor = GainAudioProcessor(
|
||||
prefs = { authStore.normalization.value },
|
||||
store = replayGains,
|
||||
)
|
||||
val renderersFactory = object : DefaultRenderersFactory(context) {
|
||||
override fun buildAudioSink(
|
||||
context: Context,
|
||||
enableFloatOutput: Boolean,
|
||||
enableAudioOutputPlaybackParams: Boolean,
|
||||
): AudioSink = DefaultAudioSink.Builder(context)
|
||||
.setEnableFloatOutput(enableFloatOutput)
|
||||
.setEnableAudioOutputPlaybackParameters(enableAudioOutputPlaybackParams)
|
||||
.setAudioProcessors(arrayOf<AudioProcessor>(gainProcessor))
|
||||
.build()
|
||||
}
|
||||
|
||||
val exo = ExoPlayer.Builder(context, renderersFactory)
|
||||
.setMediaSourceFactory(mediaSourceFactory)
|
||||
.setAudioAttributes(
|
||||
AudioAttributes.Builder()
|
||||
@@ -153,6 +186,17 @@ class PlayerFactory @Inject constructor(
|
||||
)
|
||||
.setHandleAudioBecomingNoisy(true)
|
||||
.build()
|
||||
// The processor finds a track's neighbours in play order, which
|
||||
// depends on shuffle mode.
|
||||
gainProcessor.shuffleEnabled = exo.shuffleModeEnabled
|
||||
exo.addListener(
|
||||
object : Player.Listener {
|
||||
override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) {
|
||||
gainProcessor.shuffleEnabled = shuffleModeEnabled
|
||||
}
|
||||
},
|
||||
)
|
||||
return exo
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -19,6 +19,13 @@ import javax.inject.Singleton
|
||||
class StreamTokenProvider @Inject constructor(retrofit: Retrofit) {
|
||||
private val api: CastApi = retrofit.create()
|
||||
|
||||
suspend fun mint(trackId: String): StreamTokenResponse =
|
||||
api.streamToken(StreamTokenRequest(trackId = trackId))
|
||||
/**
|
||||
* Mints a URL for a speaker, leveled when the user's setting calls for
|
||||
* it (M464 #5002). The server answers with the plain stream when it does
|
||||
* not, so every speaker URL asks.
|
||||
*/
|
||||
suspend fun mint(trackId: String, asAlbum: Boolean = false, prerender: Boolean = false): StreamTokenResponse =
|
||||
api.streamToken(
|
||||
StreamTokenRequest(trackId = trackId, level = true, asAlbum = asAlbum, prerender = prerender),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
+74
-22
@@ -4,6 +4,8 @@ import com.fabledsword.minstrel.di.ApplicationScope
|
||||
import com.fabledsword.minstrel.models.TrackRef
|
||||
import com.fabledsword.minstrel.player.RemotePlayerState
|
||||
import com.fabledsword.minstrel.player.StreamTokenProvider
|
||||
import com.fabledsword.minstrel.player.gain.AlbumPosition
|
||||
import com.fabledsword.minstrel.player.gain.GainMath
|
||||
import com.fabledsword.minstrel.player.output.upnp.AVTransportClient
|
||||
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
|
||||
import com.fabledsword.minstrel.player.output.upnp.bareUdn
|
||||
@@ -24,6 +26,11 @@ import javax.inject.Singleton
|
||||
* with its own failure modes, and it had grown large enough to hide one:
|
||||
* every write here is a SOAP call that can fail individually, and until
|
||||
* [verifyQueueLength] nothing ever read the result back.
|
||||
*
|
||||
* Every URL sent is a leveled one (M464 #5002): the server renders the track
|
||||
* at the user's loudness gain, or hands back the plain stream when leveling
|
||||
* is off. Renders are slow enough to matter, so the track playing and the
|
||||
* one after it are rendered ahead; the rest render when the speaker asks.
|
||||
*/
|
||||
@Singleton
|
||||
class SonosQueueLoader @Inject constructor(
|
||||
@@ -32,6 +39,13 @@ class SonosQueueLoader @Inject constructor(
|
||||
private val activeUpnpHolder: ActiveUpnpHolder,
|
||||
private val remoteState: RemotePlayerState,
|
||||
) {
|
||||
// The queue as last sent to the renderer, for looking up the track after
|
||||
// the one it is playing.
|
||||
@Volatile private var sent: List<TrackRef> = emptyList()
|
||||
|
||||
// The renderer track the next one was last prerendered for.
|
||||
@Volatile private var prerenderedAfter = 0
|
||||
|
||||
suspend fun load(
|
||||
transport: AVTransportClient,
|
||||
route: OutputRoute,
|
||||
@@ -46,8 +60,10 @@ class SonosQueueLoader @Inject constructor(
|
||||
"UPnP select: add %d initial tracks (currentIndex=%d, totalQueue=%d)",
|
||||
initialBatch.size, currentIndex, queue.size,
|
||||
)
|
||||
initialBatch.forEachIndexed { idx, ref ->
|
||||
val token = streamTokens.mint(ref.id)
|
||||
sent = queue
|
||||
prerenderedAfter = currentIndex + 1
|
||||
initialBatch.indices.forEach { idx ->
|
||||
val token = mint(queue, idx, prerender = idx == currentIndex)
|
||||
transport.addURIToQueue(
|
||||
uri = token.url,
|
||||
mime = token.mime,
|
||||
@@ -64,12 +80,12 @@ class SonosQueueLoader @Inject constructor(
|
||||
Timber.w("UPnP select: Play")
|
||||
transport.play()
|
||||
Timber.w("UPnP select: initial done; backgrounding remainder")
|
||||
val remaining = queue.drop(initialEnd)
|
||||
// Verify even when there is no tail to append: the initial batch is
|
||||
// sent the same way and can be dropped the same way.
|
||||
scope.launch {
|
||||
if (remaining.isNotEmpty()) {
|
||||
extendQueueOnSonos(transport, route, remaining, initialEnd)
|
||||
prerender(queue, currentIndex + 1)
|
||||
if (initialEnd < queue.size) {
|
||||
extendQueueOnSonos(transport, route, queue, initialEnd)
|
||||
}
|
||||
verifyQueueLength(transport, route, queue)
|
||||
}
|
||||
@@ -84,15 +100,16 @@ class SonosQueueLoader @Inject constructor(
|
||||
private suspend fun extendQueueOnSonos(
|
||||
transport: AVTransportClient,
|
||||
route: OutputRoute,
|
||||
tracks: List<TrackRef>,
|
||||
queue: List<TrackRef>,
|
||||
startPosition: Int,
|
||||
) {
|
||||
val count = queue.size - startPosition
|
||||
Timber.w(
|
||||
"UPnP extend: appending %d tracks starting at position %d",
|
||||
tracks.size, startPosition + 1,
|
||||
count, startPosition + 1,
|
||||
)
|
||||
val succeeded = appendTracksToQueue(transport, route, tracks, startPosition)
|
||||
Timber.w("UPnP extend: done (%d / %d appended)", succeeded, tracks.size)
|
||||
val succeeded = appendTracksToQueue(transport, route, queue, startPosition)
|
||||
Timber.w("UPnP extend: done (%d / %d appended)", succeeded, count)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -136,18 +153,17 @@ class SonosQueueLoader @Inject constructor(
|
||||
Timber.w("UPnP verify: renderer holds %d tracks, queue intact", nrTracks)
|
||||
return
|
||||
}
|
||||
val missing = fullQueue.drop(nrTracks)
|
||||
Timber.w(
|
||||
"UPnP verify: %s holds %d of %d tracks; appending %d missing (round %d)",
|
||||
route.name, nrTracks, fullQueue.size, missing.size, round + 1,
|
||||
route.name, nrTracks, fullQueue.size, fullQueue.size - nrTracks, round + 1,
|
||||
)
|
||||
appendTracksToQueue(transport, route, missing, nrTracks)
|
||||
appendTracksToQueue(transport, route, fullQueue, nrTracks)
|
||||
}
|
||||
Timber.w("UPnP verify: gave up repairing queue length on %s", route.name)
|
||||
}
|
||||
|
||||
/**
|
||||
* Append [tracks] at [startPosition] (0-based), returning how many landed.
|
||||
* Append [queue] from [startPosition] (0-based) on, returning how many landed.
|
||||
* Tolerates individual AddURIToQueue failures — log and continue so some
|
||||
* tracks loaded is better than zero tracks loaded — and stops early after
|
||||
* [EXTEND_ABORT_AFTER_FAILURES] consecutive ones.
|
||||
@@ -155,20 +171,20 @@ class SonosQueueLoader @Inject constructor(
|
||||
private suspend fun appendTracksToQueue(
|
||||
transport: AVTransportClient,
|
||||
route: OutputRoute,
|
||||
tracks: List<TrackRef>,
|
||||
queue: List<TrackRef>,
|
||||
startPosition: Int,
|
||||
): Int {
|
||||
var consecutiveFailures = 0
|
||||
var succeeded = 0
|
||||
var aborted = false
|
||||
for ((i, ref) in tracks.withIndex()) {
|
||||
for (i in 0 until queue.size - startPosition) {
|
||||
if (aborted) break
|
||||
if (activeUpnpHolder.active.value?.routeId != route.id) {
|
||||
Timber.w("UPnP extend: cancelled at offset %d (route changed)", i)
|
||||
aborted = true
|
||||
} else {
|
||||
val outcome = runCatching {
|
||||
val token = streamTokens.mint(ref.id)
|
||||
val token = mint(queue, startPosition + i)
|
||||
transport.addURIToQueue(
|
||||
uri = token.url,
|
||||
mime = token.mime,
|
||||
@@ -220,6 +236,7 @@ class SonosQueueLoader @Inject constructor(
|
||||
newQueue: List<TrackRef>,
|
||||
): Boolean {
|
||||
val newIds = newQueue.map { it.id }
|
||||
sent = newQueue
|
||||
if (oldIds == newIds) return true
|
||||
val prefixLen = commonPrefixLength(oldIds, newIds)
|
||||
val suffixLen = commonSuffixLength(
|
||||
@@ -271,8 +288,7 @@ class SonosQueueLoader @Inject constructor(
|
||||
prefixLen + 1,
|
||||
)
|
||||
for (i in 0 until addedCount) {
|
||||
val ref = newQueue[prefixLen + i]
|
||||
val token = streamTokens.mint(ref.id)
|
||||
val token = mint(newQueue, prefixLen + i)
|
||||
transport.addURIToQueue(
|
||||
uri = token.url,
|
||||
mime = token.mime,
|
||||
@@ -283,6 +299,27 @@ class SonosQueueLoader @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called on every poll with the renderer's 1-based track number. When it
|
||||
* moves, the track after it is rendered ahead, so the speaker's fetch of
|
||||
* it finds the render ready.
|
||||
*/
|
||||
fun onRendererTrack(trackNumber: Int) {
|
||||
if (trackNumber <= 0 || trackNumber == prerenderedAfter) return
|
||||
prerenderedAfter = trackNumber
|
||||
val queue = sent
|
||||
scope.launch { prerender(queue, trackNumber) }
|
||||
}
|
||||
|
||||
private suspend fun prerender(queue: List<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 {
|
||||
val limit = minOf(a.size, b.size)
|
||||
for (i in 0 until limit) {
|
||||
@@ -299,18 +336,33 @@ class SonosQueueLoader @Inject constructor(
|
||||
return limit
|
||||
}
|
||||
|
||||
private companion object {
|
||||
companion object {
|
||||
/**
|
||||
* Whether [queue]'s track at [index] plays among its album in order,
|
||||
* judged by its neighbours in the renderer's queue, which plays
|
||||
* straight through.
|
||||
*/
|
||||
internal fun playingAsAlbum(queue: List<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
|
||||
// failures; Sonos rate-limits burst adds and a wall of failures means
|
||||
// it has stopped accepting, not that the next one might land.
|
||||
const val EXTEND_ABORT_AFTER_FAILURES = 3
|
||||
const val EXTEND_THROTTLE_MS = 50L
|
||||
private const val EXTEND_ABORT_AFTER_FAILURES = 3
|
||||
private const val EXTEND_THROTTLE_MS = 50L
|
||||
|
||||
// Verify/repair passes after a queue load. Two: one to catch the
|
||||
// common case (a rate-limit burst dropped a chunk), one to catch a
|
||||
// repair that itself got rate-limited. Beyond that the renderer is
|
||||
// refusing for a reason retrying won't fix, and the stall watchdog
|
||||
// becomes the backstop.
|
||||
const val VERIFY_ROUNDS = 2
|
||||
private const val VERIFY_ROUNDS = 2
|
||||
}
|
||||
}
|
||||
|
||||
+55
@@ -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,
|
||||
}
|
||||
+67
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -41,6 +41,10 @@ data class AboutUiState(
|
||||
// check must decide on. Held in state rather than read inline so a test
|
||||
// can drive the comparison without a BuildConfig.
|
||||
val installedCode: Long = BuildConfig.VERSION_CODE.toLong(),
|
||||
// A debug build is signed with this machine's debug key, so the server's
|
||||
// release-signed APK can never install over it (family idea #5103,
|
||||
// practice 9). It updates from Android Studio instead.
|
||||
val selfUpdates: Boolean = !BuildConfig.DEBUG,
|
||||
val isChecking: Boolean = false,
|
||||
val installStage: InstallStage = InstallStage.IDLE,
|
||||
val installMessage: String? = null,
|
||||
@@ -67,7 +71,7 @@ class AboutCardViewModel @Inject constructor(
|
||||
val state: StateFlow<AboutUiState> = internal.asStateFlow()
|
||||
|
||||
fun checkForUpdates() {
|
||||
if (internal.value.isChecking) return
|
||||
if (internal.value.isChecking || !internal.value.selfUpdates) return
|
||||
viewModelScope.launch {
|
||||
internal.update { it.copy(isChecking = true, installMessage = null) }
|
||||
val installed = internal.value.installedVersion
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
+44
@@ -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,
|
||||
)
|
||||
}
|
||||
NormalizationCard()
|
||||
AppearanceCard(themeMode = themeMode, onPick = onPickTheme)
|
||||
StorageCard()
|
||||
AboutCard()
|
||||
@@ -382,6 +383,14 @@ private fun AboutCard(viewModel: AboutCardViewModel = hiltViewModel()) {
|
||||
|
||||
@Composable
|
||||
private fun UpdateControls(state: AboutUiState, viewModel: AboutCardViewModel) {
|
||||
if (!state.selfUpdates) {
|
||||
Text(
|
||||
text = "Debug build: updates install from Android Studio.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
return
|
||||
}
|
||||
UpdateCheckLine(result = state.result)
|
||||
Button(
|
||||
onClick = viewModel::checkForUpdates,
|
||||
|
||||
+7
@@ -29,6 +29,9 @@ private const val POLL_INTERVAL_MS = 24 * 60 * 60 * 1000L
|
||||
* restart re-shows it, which is acceptable nudging for v1 (matches
|
||||
* Flutter). Server 404 / network errors stay silent. Constructed at
|
||||
* launch via the construct-the-singleton trick in `MinstrelApplication`.
|
||||
*
|
||||
* A debug build never polls: it is signed with a local debug key, so the
|
||||
* server's release-signed APK could never install over it (#5103).
|
||||
*/
|
||||
@Singleton
|
||||
class UpdateBannerController @Inject constructor(
|
||||
@@ -45,6 +48,10 @@ class UpdateBannerController @Inject constructor(
|
||||
}.stateIn(scope, SharingStarted.Eagerly, null)
|
||||
|
||||
init {
|
||||
if (!BuildConfig.DEBUG) startPolling()
|
||||
}
|
||||
|
||||
private fun startPolling() {
|
||||
scope.launch {
|
||||
while (true) {
|
||||
runOnce()
|
||||
|
||||
@@ -17,8 +17,12 @@
|
||||
hostnames rather than CIDR ranges, and both sets of hosts above are unknowable
|
||||
until runtime. So a permissive base-config is an honest description of our
|
||||
situation — the gain over the manifest attribute is that the reasoning now
|
||||
lives somewhere, and there is one place to tighten if a future settings screen
|
||||
can distinguish a LAN server from a WAN one.
|
||||
lives somewhere.
|
||||
|
||||
The LAN/WAN line this file cannot draw is drawn in code instead:
|
||||
api/CleartextGuard.kt refuses plain http:// to the Minstrel server whenever
|
||||
the connection lands on a public address (family baseline #5105, practice
|
||||
13). LAN servers and UPnP speakers are unaffected.
|
||||
|
||||
Worth stating because it looks worse than it is: this is NOT a tamper risk for
|
||||
the in-app updater. An APK altered in transit and re-signed is rejected by the
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Vendored
+24
@@ -1,6 +1,8 @@
|
||||
package com.fabledsword.minstrel.cache.mutations
|
||||
|
||||
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
|
||||
import com.fabledsword.minstrel.settings.data.NormalizationMode
|
||||
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.test.assertEquals
|
||||
@@ -129,4 +131,26 @@ class SupersededToggleIdsTest {
|
||||
fun `an empty queue collapses nothing`() {
|
||||
assertTrue(supersededToggleIds(emptyList(), json).isEmpty())
|
||||
}
|
||||
|
||||
private fun normalizationRow(id: Long, mode: NormalizationMode) = CachedMutationEntity(
|
||||
id = id,
|
||||
kind = MutationKind.NORMALIZATION_SET,
|
||||
payload = json.encodeToString(
|
||||
NormalizationPrefs.serializer(),
|
||||
NormalizationPrefs.DEFAULT.copy(mode = mode),
|
||||
),
|
||||
)
|
||||
|
||||
// There is one preference per user, so any two queued changes to it
|
||||
// collapse, and only the newest is sent (#4998).
|
||||
@Test
|
||||
fun `only the newest queued normalization change survives`() {
|
||||
val rows = listOf(
|
||||
normalizationRow(1, NormalizationMode.TRACK),
|
||||
snoozeRow(2, "mb-a", desiredSnoozed = true),
|
||||
normalizationRow(3, NormalizationMode.OFF),
|
||||
normalizationRow(4, NormalizationMode.ALBUM),
|
||||
)
|
||||
assertEquals(setOf(1L, 3L), supersededToggleIds(rows, json))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
+43
@@ -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))))
|
||||
}
|
||||
}
|
||||
+90
@@ -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()) }
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ kotlinx-serialization = "1.7.3"
|
||||
kotlinx-coroutines = "1.9.0"
|
||||
kotlinx-datetime = "0.6.1"
|
||||
kotlinx-serialization-converter = "1.0.0"
|
||||
media3 = "1.10.1"
|
||||
media3 = "1.11.0"
|
||||
mediarouter = "1.7.0"
|
||||
coil = "3.0.0-rc02"
|
||||
palette = "1.0.0"
|
||||
|
||||
@@ -13,6 +13,7 @@ require (
|
||||
github.com/jackc/pgx/v5 v5.9.2
|
||||
github.com/stretchr/testify v1.11.1
|
||||
golang.org/x/crypto v0.51.0
|
||||
golang.org/x/sync v0.21.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
@@ -25,7 +26,6 @@ require (
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
golang.org/x/sync v0.21.0 // indirect
|
||||
golang.org/x/sys v0.44.0 // indirect
|
||||
golang.org/x/text v0.39.0 // indirect
|
||||
)
|
||||
|
||||
@@ -46,10 +46,15 @@ func (h *handlers) handleGetLoudnessCoverage(w http.ResponseWriter, r *http.Requ
|
||||
type loudnessSettingsBody struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
BackfillConcurrency int32 `json:"backfill_concurrency"`
|
||||
LeveledCacheMB int32 `json:"leveled_cache_mb"`
|
||||
}
|
||||
|
||||
func loudnessSettingsBodyOf(s library.LoudnessSettings) loudnessSettingsBody {
|
||||
return loudnessSettingsBody{Enabled: s.Enabled, BackfillConcurrency: s.BackfillConcurrency}
|
||||
return loudnessSettingsBody{
|
||||
Enabled: s.Enabled,
|
||||
BackfillConcurrency: s.BackfillConcurrency,
|
||||
LeveledCacheMB: s.LeveledCacheMB,
|
||||
}
|
||||
}
|
||||
|
||||
// handleGetLoudnessSettings implements GET /api/admin/library/loudness-settings.
|
||||
@@ -69,6 +74,7 @@ func (h *handlers) handleUpdateLoudnessSettings(w http.ResponseWriter, r *http.R
|
||||
saved, err := h.loudnessSettings.Set(r.Context(), library.LoudnessSettings{
|
||||
Enabled: req.Enabled,
|
||||
BackfillConcurrency: req.BackfillConcurrency,
|
||||
LeveledCacheMB: req.LeveledCacheMB,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, library.ErrLoudnessSettingOutOfRange) {
|
||||
|
||||
@@ -7,6 +7,7 @@ package api
|
||||
import (
|
||||
"log/slog"
|
||||
"math/rand"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
@@ -66,6 +67,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
||||
reacqSettings: reacqSettings,
|
||||
fingerprintSettings: fpSettings,
|
||||
loudnessSettings: loudSettings,
|
||||
leveled: newLeveledRenderer(dataDir, loudSettings, logger),
|
||||
librarySize: recommendation.NewLibrarySize(nil),
|
||||
loginGuard: auth.NewLoginGuard(),
|
||||
setupToken: setupToken,
|
||||
@@ -96,6 +98,9 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
||||
// audio format from the path. The {ext} param is consumed by chi
|
||||
// and ignored by the handler (which keys off {id}). See task #610.
|
||||
api.With(auth.OptionalUser(pool, logger)).Get("/tracks/{id}/stream.{ext}", h.handleGetStream)
|
||||
// The leveled stream for Sonos/UPnP (M464 #5001): session or a
|
||||
// leveled token, like the plain stream.
|
||||
api.With(auth.OptionalUser(pool, logger)).Get("/tracks/{id}/leveled.flac", h.handleGetLeveledStream)
|
||||
|
||||
api.Group(func(authed chi.Router) {
|
||||
authed.Use(auth.RequireUser(pool, netSettings.Hops))
|
||||
@@ -109,6 +114,8 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
||||
authed.Put("/me/password", h.handleChangePassword)
|
||||
authed.Put("/me/profile", h.handleUpdateMyProfile)
|
||||
authed.Put("/me/timezone", h.handlePutTimezone)
|
||||
authed.Get("/me/normalization", h.handleGetMyNormalization)
|
||||
authed.Put("/me/normalization", h.handlePutMyNormalization)
|
||||
authed.Post("/me/api-token", h.handleRegenerateMyAPIToken)
|
||||
authed.Get("/me/subsonic-password", h.handleGetMySubsonicPassword)
|
||||
authed.Post("/me/subsonic-password", h.handleGenerateMySubsonicPassword)
|
||||
@@ -341,6 +348,10 @@ type handlers struct {
|
||||
// loudnessSettings is the loudness analysis policy (M464 #4995), the same
|
||||
// instance the loudness backfill reads. Nil serves the defaults.
|
||||
loudnessSettings *library.LoudnessSettingsService
|
||||
// leveled renders the leveled streams handed to Sonos/UPnP speakers
|
||||
// (M464 #5001). Nil when its cache directory cannot be made: a level
|
||||
// request then gets the plain stream.
|
||||
leveled *library.LeveledRenderer
|
||||
// setupToken must accompany the first registration while no users exist
|
||||
// (see auth.SetupToken). requireSetupToken is set by Mount, the only
|
||||
// production constructor; tests that build handlers directly leave it
|
||||
@@ -368,3 +379,14 @@ type handlers struct {
|
||||
// anything a client mints), which is the desired slice-1 default.
|
||||
streamSecret []byte
|
||||
}
|
||||
|
||||
// newLeveledRenderer makes the leveled-stream renderer, caching under the
|
||||
// data directory. A failure is logged and leaves leveling off for speakers.
|
||||
func newLeveledRenderer(dataDir string, settings *library.LoudnessSettingsService, logger *slog.Logger) *library.LeveledRenderer {
|
||||
r, err := library.NewLeveledRenderer(filepath.Join(dataDir, "leveled-cache"), settings, logger)
|
||||
if err != nil {
|
||||
logger.Error("api: leveled streams unavailable", "err", err)
|
||||
return nil
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -19,6 +20,17 @@ const (
|
||||
type castTokenRequest struct {
|
||||
TrackID string `json:"trackId"`
|
||||
ExpSeconds int `json:"expSeconds,omitempty"`
|
||||
// Level asks for the leveled stream (M464 #5001): the track rendered at
|
||||
// the user's loudness gain. AsAlbum says the track is playing as part of
|
||||
// its album in order, which picks album gain in auto mode; only the
|
||||
// client holding the queue knows it.
|
||||
Level bool `json:"level,omitempty"`
|
||||
AsAlbum bool `json:"asAlbum,omitempty"`
|
||||
// Prerender says the speaker will fetch this track soon: the current
|
||||
// track at a queue load, or the next one as it starts. Only those are
|
||||
// rendered ahead; a queue load mints every track and renders none of
|
||||
// the rest until the speaker asks.
|
||||
Prerender bool `json:"prerender,omitempty"`
|
||||
}
|
||||
|
||||
type castTokenResponse struct {
|
||||
@@ -31,6 +43,10 @@ type castTokenResponse struct {
|
||||
// `<res protocolInfo>` and `<dc:title>` without a follow-up round trip.
|
||||
MIME string `json:"mime"`
|
||||
Title string `json:"title"`
|
||||
// Leveled is true when URL is the leveled stream. A level request still
|
||||
// gets the plain stream when there is nothing to change: leveling off,
|
||||
// the track not yet measured, or a gain of 0.
|
||||
Leveled bool `json:"leveled"`
|
||||
}
|
||||
|
||||
// mimeForFormat returns the audio MIME type for a cast (Sonos/UPnP) URL.
|
||||
@@ -89,7 +105,8 @@ func extForFormat(format string) string {
|
||||
// Part of the output-picker UPnP slice. See
|
||||
// docs/superpowers/specs/2026-06-03-android-output-picker-upnp-design.md.
|
||||
func (h *handlers) handleCastStreamToken(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := requireUser(w, r); !ok {
|
||||
user, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req castTokenRequest
|
||||
@@ -112,6 +129,27 @@ func (h *handlers) handleCastStreamToken(w http.ResponseWriter, r *http.Request)
|
||||
expSec := clampExpSeconds(req.ExpSeconds)
|
||||
exp := time.Now().Add(time.Duration(expSec) * time.Second).Unix()
|
||||
token := SignStreamToken(h.streamSecret, req.TrackID, exp)
|
||||
path := streamURLWithExt(trackUUID, extForFormat(track.FileFormat)) +
|
||||
"?token=" + token + "&exp=" + strconv.FormatInt(exp, 10)
|
||||
mime := mimeForFormat(track.FileFormat)
|
||||
leveled := false
|
||||
if req.Level && h.leveled != nil {
|
||||
g, err := h.leveledGainFor(r.Context(), user.ID, trackUUID, req.AsAlbum)
|
||||
if err != nil {
|
||||
// The plain stream still plays; only the leveling is lost.
|
||||
h.logger.Warn("cast token: leveled gain lookup failed", "track", req.TrackID, "err", err)
|
||||
} else if !g.Unity() {
|
||||
token = SignLeveledStreamToken(h.streamSecret, req.TrackID, exp, g)
|
||||
path = leveledStreamPath(trackUUID) + leveledQuery(g, token, exp)
|
||||
mime = "audio/flac"
|
||||
leveled = true
|
||||
if req.Prerender {
|
||||
h.leveled.Prerender(library.LeveledSource{
|
||||
TrackID: req.TrackID, Path: track.FilePath, DurationMs: track.DurationMs,
|
||||
}, g)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Behind a TLS-terminating reverse proxy, r.TLS is nil even though
|
||||
// the public-facing URL is https://. UPnP devices (Sonos especially)
|
||||
@@ -131,18 +169,16 @@ func (h *handlers) handleCastStreamToken(w http.ResponseWriter, r *http.Request)
|
||||
if h := r.Header.Get("X-Forwarded-Host"); h != "" {
|
||||
host = h
|
||||
}
|
||||
// Include the file extension in the path so Sonos's URL probe sees a
|
||||
// The path carries a file extension so Sonos's URL probe sees a
|
||||
// recognizable audio file. Without it, Sonos reports TrackDuration=0
|
||||
// and seeks past 0s land "after the end" -> early track-skip.
|
||||
url := scheme + "://" + host + streamURLWithExt(trackUUID, extForFormat(track.FileFormat)) +
|
||||
"?token=" + token + "&exp=" + strconv.FormatInt(exp, 10)
|
||||
|
||||
writeJSON(w, http.StatusOK, castTokenResponse{
|
||||
Token: token,
|
||||
Exp: exp,
|
||||
URL: url,
|
||||
MIME: mimeForFormat(track.FileFormat),
|
||||
URL: scheme + "://" + host + path,
|
||||
MIME: mime,
|
||||
Title: track.Title,
|
||||
Leveled: leveled,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -552,6 +552,7 @@ SELECT a.id AS album_id,
|
||||
SELECT file_path
|
||||
FROM tracks
|
||||
WHERE album_id = a.id
|
||||
AND missing_since IS NULL
|
||||
ORDER BY disc_number NULLS LAST, track_number NULLS LAST, id
|
||||
LIMIT 1
|
||||
) t ON true
|
||||
@@ -569,7 +570,9 @@ type ListAlbumsMissingMbidWithTrackRow struct {
|
||||
|
||||
// One-shot MBID backfill: returns each album where mbid IS NULL alongside
|
||||
// one of its tracks' file_path so the worker can re-read tags. LIMIT
|
||||
// supplied by caller for batching/progress purposes.
|
||||
// supplied by caller for batching/progress purposes. The track read is
|
||||
// one still on disk; an album with none left is skipped until a scan
|
||||
// finds its files again (#5139).
|
||||
func (q *Queries) ListAlbumsMissingMbidWithTrack(ctx context.Context, limit int32) ([]ListAlbumsMissingMbidWithTrackRow, error) {
|
||||
rows, err := q.db.Query(ctx, listAlbumsMissingMbidWithTrack, limit)
|
||||
if err != nil {
|
||||
|
||||
@@ -127,7 +127,7 @@ func (q *Queries) GetLoudnessCoverage(ctx context.Context, currentVersion int16)
|
||||
}
|
||||
|
||||
const getLoudnessSettings = `-- name: GetLoudnessSettings :one
|
||||
SELECT id, enabled, backfill_concurrency, updated_at FROM loudness_settings WHERE id = true
|
||||
SELECT id, enabled, backfill_concurrency, updated_at, leveled_cache_mb FROM loudness_settings WHERE id = true
|
||||
`
|
||||
|
||||
func (q *Queries) GetLoudnessSettings(ctx context.Context) (LoudnessSetting, error) {
|
||||
@@ -138,6 +138,7 @@ func (q *Queries) GetLoudnessSettings(ctx context.Context) (LoudnessSetting, err
|
||||
&i.Enabled,
|
||||
&i.BackfillConcurrency,
|
||||
&i.UpdatedAt,
|
||||
&i.LeveledCacheMb,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -388,26 +389,29 @@ const updateLoudnessSettings = `-- name: UpdateLoudnessSettings :one
|
||||
UPDATE loudness_settings
|
||||
SET enabled = $1,
|
||||
backfill_concurrency = $2,
|
||||
leveled_cache_mb = $3,
|
||||
updated_at = now()
|
||||
WHERE id = true
|
||||
RETURNING id, enabled, backfill_concurrency, updated_at
|
||||
RETURNING id, enabled, backfill_concurrency, updated_at, leveled_cache_mb
|
||||
`
|
||||
|
||||
type UpdateLoudnessSettingsParams struct {
|
||||
Enabled bool
|
||||
BackfillConcurrency int32
|
||||
LeveledCacheMb int32
|
||||
}
|
||||
|
||||
// Whole-row write from the admin card; migration 0065's CHECK is the backstop
|
||||
// Whole-row write from the admin card; the migrations' CHECKs are the backstop
|
||||
// behind the service's own validation.
|
||||
func (q *Queries) UpdateLoudnessSettings(ctx context.Context, arg UpdateLoudnessSettingsParams) (LoudnessSetting, error) {
|
||||
row := q.db.QueryRow(ctx, updateLoudnessSettings, arg.Enabled, arg.BackfillConcurrency)
|
||||
row := q.db.QueryRow(ctx, updateLoudnessSettings, arg.Enabled, arg.BackfillConcurrency, arg.LeveledCacheMb)
|
||||
var i LoudnessSetting
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Enabled,
|
||||
&i.BackfillConcurrency,
|
||||
&i.UpdatedAt,
|
||||
&i.LeveledCacheMb,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
@@ -432,6 +432,7 @@ type LoudnessSetting struct {
|
||||
Enabled bool
|
||||
BackfillConcurrency int32
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
LeveledCacheMb int32
|
||||
}
|
||||
|
||||
type MissingReacquisition struct {
|
||||
@@ -784,6 +785,14 @@ type UserInvite struct {
|
||||
RedeemedBy pgtype.UUID
|
||||
}
|
||||
|
||||
type UserNormalizationPref struct {
|
||||
UserID pgtype.UUID
|
||||
Mode string
|
||||
TargetLufs int16
|
||||
Boost string
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type YouMightLikeAlbum struct {
|
||||
UserID pgtype.UUID
|
||||
AlbumID pgtype.UUID
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -668,6 +668,7 @@ const listTracksMissingMbidWithPath = `-- name: ListTracksMissingMbidWithPath :m
|
||||
SELECT id, file_path
|
||||
FROM tracks
|
||||
WHERE mbid IS NULL
|
||||
AND missing_since IS NULL
|
||||
ORDER BY id
|
||||
LIMIT $1
|
||||
`
|
||||
@@ -679,6 +680,8 @@ type ListTracksMissingMbidWithPathRow struct {
|
||||
|
||||
// Track recording-MBID backfill: tracks with NULL mbid that still have
|
||||
// a file to re-read. $1 caps the batch (mirrors the album backfill).
|
||||
// Missing tracks are skipped: there is no file, and opening it anyway
|
||||
// logged a warning per track on every scan (#5139).
|
||||
func (q *Queries) ListTracksMissingMbidWithPath(ctx context.Context, limit int32) ([]ListTracksMissingMbidWithPathRow, error) {
|
||||
rows, err := q.db.Query(ctx, listTracksMissingMbidWithPath, limit)
|
||||
if err != nil {
|
||||
|
||||
@@ -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);
|
||||
@@ -145,7 +145,9 @@ UPDATE albums
|
||||
-- name: ListAlbumsMissingMbidWithTrack :many
|
||||
-- One-shot MBID backfill: returns each album where mbid IS NULL alongside
|
||||
-- one of its tracks' file_path so the worker can re-read tags. LIMIT
|
||||
-- supplied by caller for batching/progress purposes.
|
||||
-- supplied by caller for batching/progress purposes. The track read is
|
||||
-- one still on disk; an album with none left is skipped until a scan
|
||||
-- finds its files again (#5139).
|
||||
SELECT a.id AS album_id,
|
||||
a.artist_id AS artist_id,
|
||||
a.title AS title,
|
||||
@@ -155,6 +157,7 @@ SELECT a.id AS album_id,
|
||||
SELECT file_path
|
||||
FROM tracks
|
||||
WHERE album_id = a.id
|
||||
AND missing_since IS NULL
|
||||
ORDER BY disc_number NULLS LAST, track_number NULLS LAST, id
|
||||
LIMIT 1
|
||||
) t ON true
|
||||
|
||||
@@ -67,11 +67,12 @@ SELECT count(*)::bigint AS total,
|
||||
SELECT * FROM loudness_settings WHERE id = true;
|
||||
|
||||
-- name: UpdateLoudnessSettings :one
|
||||
-- Whole-row write from the admin card; migration 0065's CHECK is the backstop
|
||||
-- Whole-row write from the admin card; the migrations' CHECKs are the backstop
|
||||
-- behind the service's own validation.
|
||||
UPDATE loudness_settings
|
||||
SET enabled = sqlc.arg(enabled),
|
||||
backfill_concurrency = sqlc.arg(backfill_concurrency),
|
||||
leveled_cache_mb = sqlc.arg(leveled_cache_mb),
|
||||
updated_at = now()
|
||||
WHERE id = true
|
||||
RETURNING *;
|
||||
|
||||
@@ -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;
|
||||
@@ -26,9 +26,12 @@ RETURNING *;
|
||||
-- name: ListTracksMissingMbidWithPath :many
|
||||
-- Track recording-MBID backfill: tracks with NULL mbid that still have
|
||||
-- a file to re-read. $1 caps the batch (mirrors the album backfill).
|
||||
-- Missing tracks are skipped: there is no file, and opening it anyway
|
||||
-- logged a warning per track on every scan (#5139).
|
||||
SELECT id, file_path
|
||||
FROM tracks
|
||||
WHERE mbid IS NULL
|
||||
AND missing_since IS NULL
|
||||
ORDER BY id
|
||||
LIMIT $1;
|
||||
|
||||
|
||||
@@ -93,6 +93,7 @@ var dataTables = []string{
|
||||
"track_fingerprints", // M400
|
||||
"track_loudness", // M464
|
||||
"album_loudness", // M464
|
||||
"user_normalization_prefs", // M464
|
||||
"tracks",
|
||||
"albums",
|
||||
"artists",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -22,17 +22,27 @@ type LoudnessSettings struct {
|
||||
// values, so normalization keeps working for them.
|
||||
Enabled bool
|
||||
BackfillConcurrency int32
|
||||
// LeveledCacheMB caps the disk the leveled-stream renders for Sonos and
|
||||
// UPnP speakers may use (#5001); the least recently played go first.
|
||||
LeveledCacheMB int32
|
||||
// UpdatedAt is set by the database; ignored by Set.
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// DefaultLoudnessSettings mirrors migration 0065's column defaults, so a
|
||||
// DefaultLoudnessSettings mirrors migrations 0065 and 0068's column defaults, so a
|
||||
// database that cannot be read still analyzes the way a fresh install does.
|
||||
var DefaultLoudnessSettings = LoudnessSettings{
|
||||
Enabled: true,
|
||||
BackfillConcurrency: loudnessBackfillConcurrency,
|
||||
LeveledCacheMB: 2048,
|
||||
}
|
||||
|
||||
// The leveled-stream cache's bounds, as migration 0068's CHECK has them.
|
||||
const (
|
||||
minLeveledCacheMB = 256
|
||||
maxLeveledCacheMB = 65536
|
||||
)
|
||||
|
||||
// ErrLoudnessSettingOutOfRange is returned by Set for a value migration 0065's
|
||||
// CHECK would reject, so the API answers 400 naming the field.
|
||||
var ErrLoudnessSettingOutOfRange = errors.New("loudness setting out of range")
|
||||
@@ -78,6 +88,7 @@ func (s *LoudnessSettingsService) Set(ctx context.Context, in LoudnessSettings)
|
||||
row, err := dbq.New(s.pool).UpdateLoudnessSettings(ctx, dbq.UpdateLoudnessSettingsParams{
|
||||
Enabled: in.Enabled,
|
||||
BackfillConcurrency: in.BackfillConcurrency,
|
||||
LeveledCacheMb: in.LeveledCacheMB,
|
||||
})
|
||||
if err != nil {
|
||||
return LoudnessSettings{}, fmt.Errorf("loudness settings: save: %w", err)
|
||||
@@ -94,6 +105,10 @@ func validateLoudnessSettings(in LoudnessSettings) error {
|
||||
return fmt.Errorf("%w: backfill_concurrency must be %d-%d",
|
||||
ErrLoudnessSettingOutOfRange, minBackfillConcurrency, maxBackfillConcurrency)
|
||||
}
|
||||
if in.LeveledCacheMB < minLeveledCacheMB || in.LeveledCacheMB > maxLeveledCacheMB {
|
||||
return fmt.Errorf("%w: leveled_cache_mb must be %d-%d",
|
||||
ErrLoudnessSettingOutOfRange, minLeveledCacheMB, maxLeveledCacheMB)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -101,6 +116,7 @@ func loudnessSettingsFromRow(row dbq.LoudnessSetting) LoudnessSettings {
|
||||
return LoudnessSettings{
|
||||
Enabled: row.Enabled,
|
||||
BackfillConcurrency: row.BackfillConcurrency,
|
||||
LeveledCacheMB: row.LeveledCacheMb,
|
||||
UpdatedAt: row.UpdatedAt.Time,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,14 +233,27 @@ func TestBackfillLoudnessResult_Add(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestValidateLoudnessSettings(t *testing.T) {
|
||||
for _, n := range []int32{minBackfillConcurrency, maxBackfillConcurrency} {
|
||||
if err := validateLoudnessSettings(LoudnessSettings{BackfillConcurrency: n}); err != nil {
|
||||
t.Errorf("concurrency %d rejected: %v", n, err)
|
||||
with := func(concurrency, cacheMB int32) LoudnessSettings {
|
||||
s := DefaultLoudnessSettings
|
||||
s.BackfillConcurrency, s.LeveledCacheMB = concurrency, cacheMB
|
||||
return s
|
||||
}
|
||||
for _, s := range []LoudnessSettings{
|
||||
with(minBackfillConcurrency, minLeveledCacheMB),
|
||||
with(maxBackfillConcurrency, maxLeveledCacheMB),
|
||||
} {
|
||||
if err := validateLoudnessSettings(s); err != nil {
|
||||
t.Errorf("%+v rejected: %v", s, err)
|
||||
}
|
||||
}
|
||||
for _, n := range []int32{0, maxBackfillConcurrency + 1} {
|
||||
if err := validateLoudnessSettings(LoudnessSettings{BackfillConcurrency: n}); !errors.Is(err, ErrLoudnessSettingOutOfRange) {
|
||||
t.Errorf("concurrency %d: err = %v, want ErrLoudnessSettingOutOfRange", n, err)
|
||||
for _, s := range []LoudnessSettings{
|
||||
with(0, minLeveledCacheMB),
|
||||
with(maxBackfillConcurrency+1, minLeveledCacheMB),
|
||||
with(minBackfillConcurrency, minLeveledCacheMB-1),
|
||||
with(minBackfillConcurrency, maxLeveledCacheMB+1),
|
||||
} {
|
||||
if err := validateLoudnessSettings(s); !errors.Is(err, ErrLoudnessSettingOutOfRange) {
|
||||
t.Errorf("%+v: err = %v, want ErrLoudnessSettingOutOfRange", s, err)
|
||||
}
|
||||
}
|
||||
var nilSvc *LoudnessSettingsService
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -386,6 +386,8 @@ export async function getLoudnessCoverage(): Promise<LoudnessCoverage> {
|
||||
export type LoudnessSettings = {
|
||||
enabled: boolean;
|
||||
backfill_concurrency: number;
|
||||
/** Disk for the leveled copies rendered for Sonos/UPnP speakers (#5001). */
|
||||
leveled_cache_mb: number;
|
||||
};
|
||||
|
||||
export async function getLoudnessSettings(): Promise<LoudnessSettings> {
|
||||
|
||||
@@ -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';
|
||||
|
||||
// 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 form = $state<LoudnessSettings | null>(null);
|
||||
@@ -26,6 +27,13 @@
|
||||
form.backfill_concurrency >= 1 &&
|
||||
form.backfill_concurrency <= 8
|
||||
);
|
||||
const cacheOk = $derived(
|
||||
!!form &&
|
||||
Number.isInteger(form.leveled_cache_mb) &&
|
||||
form.leveled_cache_mb >= 256 &&
|
||||
form.leveled_cache_mb <= 65536
|
||||
);
|
||||
const valid = $derived(concurrencyOk && cacheOk);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
@@ -55,7 +63,7 @@
|
||||
});
|
||||
|
||||
async function save() {
|
||||
if (!form || !concurrencyOk) return;
|
||||
if (!form || !valid) return;
|
||||
saving = true;
|
||||
try {
|
||||
saved = await updateLoudnessSettings(form);
|
||||
@@ -151,10 +159,26 @@
|
||||
/>
|
||||
</label>
|
||||
|
||||
{#if !concurrencyOk}
|
||||
<p class="text-xs text-action-destructive" data-testid="settings-problems">
|
||||
Files analyzed at once must be a whole number from 1 to 8.
|
||||
</p>
|
||||
<label class="block">
|
||||
<span class="text-sm text-text-primary">Speaker cache (MB)</span>
|
||||
<span class="block text-xs text-text-secondary">
|
||||
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}
|
||||
|
||||
<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
|
||||
focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent
|
||||
disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={!dirty || saving || !concurrencyOk}
|
||||
disabled={!dirty || saving || !valid}
|
||||
onclick={save}
|
||||
>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
|
||||
@@ -14,7 +14,7 @@ import LoudnessSettingsCard from './LoudnessSettingsCard.svelte';
|
||||
import { getLoudnessCoverage, getLoudnessSettings, updateLoudnessSettings } from '$lib/api/admin';
|
||||
import { pushToast } from '$lib/stores/toast.svelte';
|
||||
|
||||
const base: LoudnessSettings = { enabled: true, backfill_concurrency: 2 };
|
||||
const base: LoudnessSettings = { enabled: true, backfill_concurrency: 2, leveled_cache_mb: 2048 };
|
||||
const coverage: LoudnessCoverage = {
|
||||
total: 1200,
|
||||
measured: 900,
|
||||
@@ -68,7 +68,11 @@ describe('LoudnessSettingsCard', () => {
|
||||
await fireEvent.click(saveButton());
|
||||
|
||||
await waitFor(() =>
|
||||
expect(updateLoudnessSettings).toHaveBeenCalledWith({ enabled: true, backfill_concurrency: 4 })
|
||||
expect(updateLoudnessSettings).toHaveBeenCalledWith({
|
||||
enabled: true,
|
||||
backfill_concurrency: 4,
|
||||
leveled_cache_mb: 2048
|
||||
})
|
||||
);
|
||||
await waitFor(() => expect(pushToast).toHaveBeenCalledWith('Loudness analysis settings saved.'));
|
||||
// The gauge is read again after a save: switching analysis on or off changes it.
|
||||
@@ -84,6 +88,16 @@ describe('LoudnessSettingsCard', () => {
|
||||
expect(saveButton()).toHaveProperty('disabled', true);
|
||||
});
|
||||
|
||||
test('the speaker cache size is bounded too', async () => {
|
||||
await renderCard();
|
||||
const cache = screen.getByRole('spinbutton', { name: /speaker cache/i });
|
||||
await fireEvent.input(cache, { target: { value: '100' } });
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('settings-problems').textContent).toMatch(/256 to 65536/)
|
||||
);
|
||||
expect(saveButton()).toHaveProperty('disabled', true);
|
||||
});
|
||||
|
||||
test('a failed load offers a retry', async () => {
|
||||
vi.mocked(getLoudnessSettings).mockRejectedValue(new Error('boom'));
|
||||
vi.mocked(getLoudnessCoverage).mockResolvedValue(coverage);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,14 @@
|
||||
import { applyMetaThemeColor } from '$lib/theme/applyMetaThemeColor.svelte';
|
||||
import { audioLoader } from '$lib/player/audioLoader';
|
||||
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 audioEl: HTMLAudioElement | undefined = $state();
|
||||
@@ -95,8 +103,39 @@
|
||||
// Per-position fade scalar: 1 normally, ramping in/out of the
|
||||
// crossfade window at track boundaries. Pure function of position
|
||||
// + 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);
|
||||
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(() => {
|
||||
@@ -105,6 +144,7 @@
|
||||
// emitted 'playing' yet, but we still need to call play() to get there.
|
||||
const intent = player.state === 'playing' || player.state === 'loading';
|
||||
if (intent) {
|
||||
resumeGraph();
|
||||
audioEl.play().catch(() => { /* interrupted / blocked — store gets the event */ });
|
||||
} else {
|
||||
audioEl.pause();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { LayoutLoad } from './$types';
|
||||
import { bootstrap, user } from '$lib/auth/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 prerender = false;
|
||||
@@ -8,6 +9,10 @@ export const prerender = false;
|
||||
export const load: LayoutLoad = async () => {
|
||||
await bootstrap();
|
||||
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 {};
|
||||
};
|
||||
|
||||
@@ -50,7 +50,9 @@ vi.mock('$lib/api/admin', async () => {
|
||||
refetchMissingCovers: vi.fn().mockResolvedValue({ started: true }),
|
||||
researchMissingArt: vi.fn().mockResolvedValue({ version: 1 }),
|
||||
// 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(),
|
||||
getLoudnessCoverage: vi.fn().mockResolvedValue({
|
||||
total: 0, measured: 0, silent: 0, unreadable: 0, pending: 0, enabled: true
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
import MobileAppDownload from '$lib/components/MobileAppDownload.svelte';
|
||||
import ServerVersion from '$lib/components/ServerVersion.svelte';
|
||||
import ActiveSessions from '$lib/components/ActiveSessions.svelte';
|
||||
import NormalizationSetting from '$lib/components/NormalizationSetting.svelte';
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -341,6 +342,8 @@
|
||||
0 = off · most albums sound best at 0.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<NormalizationSetting />
|
||||
</section>
|
||||
|
||||
<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', () => ({
|
||||
createRecommendationMetricsQuery: () => ({
|
||||
subscribe: (run: (v: unknown) => void) => {
|
||||
|
||||
@@ -11,6 +11,16 @@ vi.mock('$lib/api/listenbrainz', () => ({
|
||||
setListenBrainzEnabled: vi.fn()
|
||||
}));
|
||||
|
||||
// The Volume leveling setting loads on mount; keep it off the network.
|
||||
vi.mock('$lib/api/normalization', async (importOriginal) => {
|
||||
const mod = await importOriginal<typeof import('$lib/api/normalization')>();
|
||||
return {
|
||||
...mod,
|
||||
getNormalization: vi.fn().mockResolvedValue(mod.DEFAULT_NORMALIZATION),
|
||||
putNormalization: vi.fn()
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('$lib/api/me', () => ({
|
||||
updateProfile: vi.fn(),
|
||||
changePassword: vi.fn(),
|
||||
|
||||
Reference in New Issue
Block a user