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;
|
# every integration package TRUNCATEs the one shared test DB;
|
||||||
# concurrent package binaries → TRUNCATE deadlocks. Serialize
|
# concurrent package binaries → TRUNCATE deadlocks. Serialize
|
||||||
# package execution (the documented local invocation too).
|
# package execution (the documented local invocation too).
|
||||||
|
# -timeout 20m: internal/api alone takes ~6.5 min under -race on
|
||||||
|
# an idle runner, and a dev and a main run sharing the runner
|
||||||
|
# pushed it past go test's default 10m (run 8368, 600.016s, the
|
||||||
|
# running test 2s old — nothing hung).
|
||||||
MINSTREL_DATABASE_URL="$MINSTREL_TEST_DATABASE_URL" go run ./cmd/minstrel migrate
|
MINSTREL_DATABASE_URL="$MINSTREL_TEST_DATABASE_URL" go run ./cmd/minstrel migrate
|
||||||
go test -p 1 -race ./...
|
go test -p 1 -race -timeout 20m ./...
|
||||||
|
|
||||||
web:
|
web:
|
||||||
runs-on: go-ci
|
runs-on: go-ci
|
||||||
@@ -352,22 +356,11 @@ jobs:
|
|||||||
- name: Unit tests
|
- name: Unit tests
|
||||||
run: ./gradlew testDebugUnitTest
|
run: ./gradlew testDebugUnitTest
|
||||||
|
|
||||||
- name: Assemble debug
|
# No debug APK is built or uploaded here. Main used to upload a
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
# debug-signed app-debug.apk: a build signed by a key regenerated in
|
||||||
run: ./gradlew assembleDebug
|
# every container, which no install can update (family idea #5103,
|
||||||
|
# practice 2). Phones get builds from android-release, signed with
|
||||||
- name: Upload debug APK
|
# the one release key, on dev and on tags.
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
|
||||||
# Stock action: it works on this forge since the runner moved to
|
|
||||||
# gitea/runner 3.x, which edits upload-artifact's client-side GHES refusal
|
|
||||||
# out of the action bundle (Scribe snippet #2271). Never @v3 — it reports
|
|
||||||
# success while Gitea serves artifacts back only through the v4 API, and
|
|
||||||
# it is what left 72 unreachable artifacts on this repo (Scribe 2270).
|
|
||||||
uses: actions/upload-artifact@v7
|
|
||||||
with:
|
|
||||||
name: minstrel-android-debug-${{ github.sha }}
|
|
||||||
path: android/app/build/outputs/apk/debug/app-debug.apk
|
|
||||||
if-no-files-found: error
|
|
||||||
|
|
||||||
# Known vulnerabilities in the Go code and the standard library it is built
|
# Known vulnerabilities in the Go code and the standard library it is built
|
||||||
# with. Runs in the SAME image the Dockerfile's builder stage uses, so the
|
# with. Runs in the SAME image the Dockerfile's builder stage uses, so the
|
||||||
@@ -530,6 +523,42 @@ jobs:
|
|||||||
-PMINSTREL_VERSION_NAME=${{ steps.ver.outputs.name }} \
|
-PMINSTREL_VERSION_NAME=${{ steps.ver.outputs.name }} \
|
||||||
-PMINSTREL_VERSION_CODE=${{ steps.ver.outputs.code }}
|
-PMINSTREL_VERSION_CODE=${{ steps.ver.outputs.code }}
|
||||||
|
|
||||||
|
# The APK every phone updates from must carry THE release key: Android
|
||||||
|
# updates an app in place only when the signer matches, so an APK
|
||||||
|
# signed by any other key (debug, a regenerated keystore, a swapped
|
||||||
|
# secret) reaches no installed phone. The certificate's digest is
|
||||||
|
# pinned below; it is public, not a secret. Gradle signs with the
|
||||||
|
# release key or leaves the APK unsigned, and an unsigned build fails
|
||||||
|
# here too, as there is no app-release.apk to verify (family idea
|
||||||
|
# #5103, practice 3). apksigner, not keytool: keytool prints nothing
|
||||||
|
# for a v2-only APK.
|
||||||
|
#
|
||||||
|
# Rotating the key on purpose means every install must be removed and
|
||||||
|
# reinstalled; change the digest here in the same commit.
|
||||||
|
- name: The APK carries the release key
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
# CN=Minstrel, O=FabledSword. Read from run 8446 (#5116).
|
||||||
|
RELEASE_CERT_SHA256: 43d183307bc46b821789d90444a960b137f78f2166ff431efb2406d0fceaf612
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
sdk="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}"
|
||||||
|
signer="$(ls "$sdk"/build-tools/*/apksigner 2>/dev/null | sort -V | tail -1 || true)"
|
||||||
|
test -n "$signer" || { echo "::error::no apksigner under '$sdk/build-tools'"; exit 1; }
|
||||||
|
certs="$("$signer" verify --print-certs app/build/outputs/apk/release/app-release.apk)"
|
||||||
|
printf '%s\n' "$certs" | grep -E '^Signer #[0-9]+ certificate (DN|SHA-256 digest)'
|
||||||
|
# One signer, and it is ours. A second signer would be a lineage or
|
||||||
|
# a mistake; either way not something to ship unexamined.
|
||||||
|
digests="$(printf '%s\n' "$certs" | sed -n 's/^Signer #[0-9]* certificate SHA-256 digest: //p')"
|
||||||
|
if [ "$digests" != "$RELEASE_CERT_SHA256" ]; then
|
||||||
|
if printf '%s' "$certs" | grep -q 'CN=Android Debug'; then
|
||||||
|
echo "::error::the release APK is signed with a debug key"
|
||||||
|
else
|
||||||
|
echo "::error::the release APK is not signed by the release key: got '${digests//$'\n'/ }', want ${RELEASE_CERT_SHA256}"
|
||||||
|
fi
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Upload APK as workflow artifact
|
- name: Upload APK as workflow artifact
|
||||||
# Stock action (snippet #2271) — never @v3, which uploads something Gitea
|
# Stock action (snippet #2271) — never @v3, which uploads something Gitea
|
||||||
# will never serve back. This is the producing half of a pair:
|
# will never serve back. This is the producing half of a pair:
|
||||||
|
|||||||
@@ -72,9 +72,13 @@ android {
|
|||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
"proguard-rules.pro",
|
"proguard-rules.pro",
|
||||||
)
|
)
|
||||||
|
// Signed with the release key or not at all. Falling back to the
|
||||||
|
// debug key made a missing secret into a published APK that no
|
||||||
|
// install could ever update (family idea #5103, practice 2). An
|
||||||
|
// unsigned build installs nowhere, so the gap shows at once.
|
||||||
signingConfig =
|
signingConfig =
|
||||||
if (System.getenv("ANDROID_KEYSTORE_PATH").isNullOrEmpty()) {
|
if (System.getenv("ANDROID_KEYSTORE_PATH").isNullOrEmpty()) {
|
||||||
signingConfigs.getByName("debug")
|
null
|
||||||
} else {
|
} else {
|
||||||
signingConfigs.getByName("release")
|
signingConfigs.getByName("release")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,14 @@ class BaseUrlInterceptor @Inject constructor(
|
|||||||
.port(baseUrl.port)
|
.port(baseUrl.port)
|
||||||
.build()
|
.build()
|
||||||
} ?: original.url
|
} ?: original.url
|
||||||
return chain.proceed(original.newBuilder().url(rewritten).build())
|
return chain.proceed(
|
||||||
|
original.newBuilder()
|
||||||
|
.url(rewritten)
|
||||||
|
// Lets CleartextGuardInterceptor tell server requests from
|
||||||
|
// external fetches once the placeholder host is gone.
|
||||||
|
.tag(MinstrelServerRequest::class.java, MinstrelServerRequest)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package com.fabledsword.minstrel.api
|
||||||
|
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.Response
|
||||||
|
import java.io.IOException
|
||||||
|
import java.net.Inet4Address
|
||||||
|
import java.net.Inet6Address
|
||||||
|
import java.net.InetAddress
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks a request as bound for the Minstrel server, set by
|
||||||
|
* [BaseUrlInterceptor] when it retargets the placeholder host. Those are the
|
||||||
|
* requests that carry the session cookie and the password.
|
||||||
|
*/
|
||||||
|
object MinstrelServerRequest
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plain `http://` to the Minstrel server is allowed only when the connection
|
||||||
|
* actually lands on a private address (family security baseline #5105,
|
||||||
|
* practice 13).
|
||||||
|
*
|
||||||
|
* Cleartext stays permitted app-wide for LAN servers and UPnP
|
||||||
|
* (network_security_config.xml, #2439), but a password or session cookie sent
|
||||||
|
* over plain HTTP to a public address can be read by anyone on the path.
|
||||||
|
*
|
||||||
|
* Checked per connection, on the address the socket really reached, not on
|
||||||
|
* the URL when it was typed: a name that resolved to the home network when it
|
||||||
|
* was entered resolves to a public address once the phone leaves home, and
|
||||||
|
* that is exactly when the password would go out in the clear. A network
|
||||||
|
* interceptor runs after the connection is made and before any request byte
|
||||||
|
* is written, so nothing is sent.
|
||||||
|
*/
|
||||||
|
class CleartextGuardInterceptor(
|
||||||
|
// The policy is a parameter so a test can refuse loopback, the only
|
||||||
|
// address a test server can listen on.
|
||||||
|
private val allows: (InetAddress) -> Boolean = CleartextPolicy::allows,
|
||||||
|
) : Interceptor {
|
||||||
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
|
val request = chain.request()
|
||||||
|
if (request.isHttps || request.tag(MinstrelServerRequest::class.java) == null) {
|
||||||
|
return chain.proceed(request)
|
||||||
|
}
|
||||||
|
val address = chain.connection()?.route()?.socketAddress?.address
|
||||||
|
if (address != null && !allows(address)) {
|
||||||
|
throw CleartextToPublicHostException(request.url.host)
|
||||||
|
}
|
||||||
|
return chain.proceed(request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The server was reached over plain HTTP at a public address, and refused. */
|
||||||
|
class CleartextToPublicHostException(host: String) :
|
||||||
|
IOException("refusing plain http:// to $host: it is a public address")
|
||||||
|
|
||||||
|
/** Which addresses plain HTTP may reach: the home network, never the internet. */
|
||||||
|
object CleartextPolicy {
|
||||||
|
private const val CGNAT_FIRST_OCTET = 100
|
||||||
|
private const val CGNAT_SECOND_MASK = 0xC0
|
||||||
|
private const val CGNAT_SECOND_PREFIX = 64
|
||||||
|
private const val ULA_MASK = 0xFE
|
||||||
|
private const val ULA_PREFIX = 0xFC
|
||||||
|
private const val BYTE = 0xFF
|
||||||
|
|
||||||
|
fun allows(address: InetAddress): Boolean =
|
||||||
|
address.isLoopbackAddress ||
|
||||||
|
address.isSiteLocalAddress || // 10/8, 172.16/12, 192.168/16
|
||||||
|
address.isLinkLocalAddress || // 169.254/16, fe80::/10
|
||||||
|
address.isAnyLocalAddress ||
|
||||||
|
isCarrierGradeNat(address) ||
|
||||||
|
isUniqueLocal(address)
|
||||||
|
|
||||||
|
// 100.64/10. Tailscale and other overlay VPNs hand these out; the overlay
|
||||||
|
// encrypts the traffic itself.
|
||||||
|
private fun isCarrierGradeNat(address: InetAddress): Boolean {
|
||||||
|
if (address !is Inet4Address) return false
|
||||||
|
val b = address.address
|
||||||
|
return (b[0].toInt() and BYTE) == CGNAT_FIRST_OCTET &&
|
||||||
|
(b[1].toInt() and CGNAT_SECOND_MASK) == CGNAT_SECOND_PREFIX
|
||||||
|
}
|
||||||
|
|
||||||
|
// fc00::/7, IPv6's private range.
|
||||||
|
private fun isUniqueLocal(address: InetAddress): Boolean =
|
||||||
|
address is Inet6Address && (address.address[0].toInt() and ULA_MASK) == ULA_PREFIX
|
||||||
|
}
|
||||||
@@ -38,6 +38,7 @@ object ErrorCopy {
|
|||||||
*/
|
*/
|
||||||
fun fromThrowable(t: Throwable): String = when (t) {
|
fun fromThrowable(t: Throwable): String = when (t) {
|
||||||
is HttpException -> fromHttp(t)
|
is HttpException -> fromHttp(t)
|
||||||
|
is CleartextToPublicHostException -> messageFor("cleartext_public")
|
||||||
is IOException -> messageFor("connection_refused")
|
is IOException -> messageFor("connection_refused")
|
||||||
else -> TABLE.getValue("unknown")
|
else -> TABLE.getValue("unknown")
|
||||||
}
|
}
|
||||||
@@ -101,6 +102,9 @@ object ErrorCopy {
|
|||||||
"mbid_required" to "An MBID is required for this lookup.",
|
"mbid_required" to "An MBID is required for this lookup.",
|
||||||
"system_playlist_readonly" to "System playlists can't be edited directly.",
|
"system_playlist_readonly" to "System playlists can't be edited directly.",
|
||||||
"connection_refused" to "Couldn't reach the server. Check the URL and try again.",
|
"connection_refused" to "Couldn't reach the server. Check the URL and try again.",
|
||||||
|
"cleartext_public" to
|
||||||
|
"This server is on the internet, so its URL must start with https://. " +
|
||||||
|
"Plain http:// only works on your home network.",
|
||||||
"lidarr_unreachable" to
|
"lidarr_unreachable" to
|
||||||
"Lidarr is unreachable right now. Try again, or check Admin → Integrations.",
|
"Lidarr is unreachable right now. Try again, or check Admin → Integrations.",
|
||||||
"lidarr_disabled" to "Lidarr integration is not enabled.",
|
"lidarr_disabled" to "Lidarr integration is not enabled.",
|
||||||
|
|||||||
@@ -70,6 +70,9 @@ object NetworkModule {
|
|||||||
.addInterceptor(auth)
|
.addInterceptor(auth)
|
||||||
.addInterceptor(baseUrl)
|
.addInterceptor(baseUrl)
|
||||||
.addInterceptor(logging)
|
.addInterceptor(logging)
|
||||||
|
// A network interceptor, so it sees the address the connection
|
||||||
|
// really reached and runs before any request byte is written.
|
||||||
|
.addNetworkInterceptor(CleartextGuardInterceptor())
|
||||||
.connectTimeout(CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
.connectTimeout(CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||||
.readTimeout(READ_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
.readTimeout(READ_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||||
.build()
|
.build()
|
||||||
|
|||||||
@@ -29,11 +29,20 @@ interface CastApi {
|
|||||||
* Request body. [expSeconds] is clamped server-side to [60, 86400];
|
* Request body. [expSeconds] is clamped server-side to [60, 86400];
|
||||||
* the 21_600 default (6h) is long enough to play through any typical
|
* the 21_600 default (6h) is long enough to play through any typical
|
||||||
* track without re-minting mid-playback.
|
* track without re-minting mid-playback.
|
||||||
|
*
|
||||||
|
* [level] asks for the leveled stream (M464 #5001): the track rendered at
|
||||||
|
* the user's loudness gain, which the server works out from their setting.
|
||||||
|
* [asAlbum] says the track plays among its album in order, which picks
|
||||||
|
* album gain in auto mode. [prerender] says the speaker will fetch it
|
||||||
|
* soon, so the server renders it ahead.
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
data class StreamTokenRequest(
|
data class StreamTokenRequest(
|
||||||
val trackId: String,
|
val trackId: String,
|
||||||
val expSeconds: Int = 21_600,
|
val expSeconds: Int = 21_600,
|
||||||
|
val level: Boolean = false,
|
||||||
|
val asAlbum: Boolean = false,
|
||||||
|
val prerender: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -53,4 +62,6 @@ data class StreamTokenResponse(
|
|||||||
val url: String,
|
val url: String,
|
||||||
val mime: String = "audio/mpeg",
|
val mime: String = "audio/mpeg",
|
||||||
val title: String = "",
|
val title: String = "",
|
||||||
|
/** [url] is the leveled stream; false when leveling is off or changes nothing. */
|
||||||
|
val leveled: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.fabledsword.minstrel.api.endpoints
|
|||||||
import com.fabledsword.minstrel.models.wire.ListenBrainzStatusWire
|
import com.fabledsword.minstrel.models.wire.ListenBrainzStatusWire
|
||||||
import com.fabledsword.minstrel.models.wire.MyProfileWire
|
import com.fabledsword.minstrel.models.wire.MyProfileWire
|
||||||
import com.fabledsword.minstrel.models.wire.SystemPlaylistsStatusWire
|
import com.fabledsword.minstrel.models.wire.SystemPlaylistsStatusWire
|
||||||
|
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import retrofit2.http.Body
|
import retrofit2.http.Body
|
||||||
@@ -59,6 +60,14 @@ interface MeApi {
|
|||||||
*/
|
*/
|
||||||
@PUT("api/me/listenbrainz")
|
@PUT("api/me/listenbrainz")
|
||||||
suspend fun setListenBrainz(@Body body: ListenBrainzPutBody): ListenBrainzStatusWire
|
suspend fun setListenBrainz(@Body body: ListenBrainzPutBody): ListenBrainzStatusWire
|
||||||
|
|
||||||
|
/** The caller's loudness-normalization preference, or the defaults if never set. */
|
||||||
|
@GET("api/me/normalization")
|
||||||
|
suspend fun getNormalization(): NormalizationPrefs
|
||||||
|
|
||||||
|
/** Replaces the whole preference; returns what the server stored. */
|
||||||
|
@PUT("api/me/normalization")
|
||||||
|
suspend fun putNormalization(@Body body: NormalizationPrefs): NormalizationPrefs
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.fabledsword.minstrel.api.endpoints
|
||||||
|
|
||||||
|
import com.fabledsword.minstrel.models.wire.ReplayGainResponseWire
|
||||||
|
import retrofit2.http.GET
|
||||||
|
import retrofit2.http.Query
|
||||||
|
|
||||||
|
/** The player's loudness lookup (#4997), kept apart from the browse surface in [LibraryApi]. */
|
||||||
|
interface ReplayGainApi {
|
||||||
|
/**
|
||||||
|
* ReplayGain values for up to 200 comma-separated track ids. An id
|
||||||
|
* missing from `items` has not been measured yet.
|
||||||
|
*/
|
||||||
|
@GET("api/tracks/replay-gain")
|
||||||
|
suspend fun getReplayGain(@Query("ids") ids: String): ReplayGainResponseWire
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import com.fabledsword.minstrel.cache.audiocache.CacheSettings
|
|||||||
import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao
|
import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao
|
||||||
import com.fabledsword.minstrel.cache.db.entities.AuthSessionEntity
|
import com.fabledsword.minstrel.cache.db.entities.AuthSessionEntity
|
||||||
import com.fabledsword.minstrel.di.ApplicationScope
|
import com.fabledsword.minstrel.di.ApplicationScope
|
||||||
|
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Deferred
|
import kotlinx.coroutines.Deferred
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
@@ -77,6 +78,9 @@ class AuthStore @Inject constructor(
|
|||||||
private val diagnosticsOptOutState = MutableStateFlow(false)
|
private val diagnosticsOptOutState = MutableStateFlow(false)
|
||||||
val diagnosticsOptOut: StateFlow<Boolean> = diagnosticsOptOutState.asStateFlow()
|
val diagnosticsOptOut: StateFlow<Boolean> = diagnosticsOptOutState.asStateFlow()
|
||||||
|
|
||||||
|
private val normalizationState = MutableStateFlow(NormalizationPrefs.DEFAULT)
|
||||||
|
val normalization: StateFlow<NormalizationPrefs> = normalizationState.asStateFlow()
|
||||||
|
|
||||||
private val json = Json { ignoreUnknownKeys = true }
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
// Serialises every cookie persist with the one-time hydration, so a
|
// Serialises every cookie persist with the one-time hydration, so a
|
||||||
@@ -99,6 +103,7 @@ class AuthStore @Inject constructor(
|
|||||||
clientIdState.value = row?.clientId
|
clientIdState.value = row?.clientId
|
||||||
cacheSettingsState.value = decodeCacheSettings(row?.cacheSettingsJson)
|
cacheSettingsState.value = decodeCacheSettings(row?.cacheSettingsJson)
|
||||||
diagnosticsOptOutState.value = row?.diagnosticsOptOut ?: false
|
diagnosticsOptOutState.value = row?.diagnosticsOptOut ?: false
|
||||||
|
normalizationState.value = decodeNormalization(row?.normalizationJson)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -110,6 +115,13 @@ class AuthStore @Inject constructor(
|
|||||||
}.getOrDefault(CacheSettings.DEFAULT)
|
}.getOrDefault(CacheSettings.DEFAULT)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun decodeNormalization(raw: String?): NormalizationPrefs {
|
||||||
|
if (raw.isNullOrEmpty()) return NormalizationPrefs.DEFAULT
|
||||||
|
return runCatching {
|
||||||
|
json.decodeFromString(NormalizationPrefs.serializer(), raw)
|
||||||
|
}.getOrDefault(NormalizationPrefs.DEFAULT)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Suspends until the stored session cookie has been loaded into
|
* Suspends until the stored session cookie has been loaded into
|
||||||
* [sessionCookie], or [HYDRATION_DEADLINE_MS] passes (rule 156: a wedged
|
* [sessionCookie], or [HYDRATION_DEADLINE_MS] passes (rule 156: a wedged
|
||||||
@@ -187,6 +199,12 @@ class AuthStore @Inject constructor(
|
|||||||
scope.launch { persistDiagnosticsOptOut(value) }
|
scope.launch { persistDiagnosticsOptOut(value) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setNormalization(value: NormalizationPrefs) {
|
||||||
|
normalizationState.value = value
|
||||||
|
val encoded = json.encodeToString(NormalizationPrefs.serializer(), value)
|
||||||
|
scope.launch { persistNormalization(encoded) }
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun persistLegacyCookie(value: String?) {
|
private suspend fun persistLegacyCookie(value: String?) {
|
||||||
if (dao.get() == null) {
|
if (dao.get() == null) {
|
||||||
dao.upsert(currentEntity().copy(sessionCookie = value))
|
dao.upsert(currentEntity().copy(sessionCookie = value))
|
||||||
@@ -243,6 +261,14 @@ class AuthStore @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun persistNormalization(json: String) {
|
||||||
|
if (dao.get() == null) {
|
||||||
|
dao.upsert(currentEntity().copy(normalizationJson = json))
|
||||||
|
} else {
|
||||||
|
dao.setNormalizationJson(json)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun currentEntity(): AuthSessionEntity = AuthSessionEntity(
|
private fun currentEntity(): AuthSessionEntity = AuthSessionEntity(
|
||||||
id = ROW_ID,
|
id = ROW_ID,
|
||||||
// Never copied into the row: the cookie lives in the vault, and
|
// Never copied into the row: the cookie lives in the vault, and
|
||||||
@@ -257,6 +283,10 @@ class AuthStore @Inject constructor(
|
|||||||
cacheSettingsState.value,
|
cacheSettingsState.value,
|
||||||
),
|
),
|
||||||
diagnosticsOptOut = diagnosticsOptOutState.value,
|
diagnosticsOptOut = diagnosticsOptOutState.value,
|
||||||
|
normalizationJson = json.encodeToString(
|
||||||
|
NormalizationPrefs.serializer(),
|
||||||
|
normalizationState.value,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
+34
-1
@@ -3,6 +3,8 @@ package com.fabledsword.minstrel.cache.db
|
|||||||
import androidx.room.Database
|
import androidx.room.Database
|
||||||
import androidx.room.RoomDatabase
|
import androidx.room.RoomDatabase
|
||||||
import androidx.room.TypeConverters
|
import androidx.room.TypeConverters
|
||||||
|
import androidx.room.migration.Migration
|
||||||
|
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
import com.fabledsword.minstrel.cache.db.dao.AudioCacheIndexDao
|
import com.fabledsword.minstrel.cache.db.dao.AudioCacheIndexDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao
|
import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao
|
||||||
@@ -65,13 +67,22 @@ import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
|
|||||||
AuthSessionEntity::class,
|
AuthSessionEntity::class,
|
||||||
DiagnosticEventEntity::class,
|
DiagnosticEventEntity::class,
|
||||||
],
|
],
|
||||||
|
// v10: + cached_tracks.trackGain/trackPeak and cached_albums.albumGain/
|
||||||
|
// albumPeak, the ReplayGain values the player levels by (M464 #5000).
|
||||||
|
// MIGRATION_9_10 also rewinds the sync cursor, so the next sync re-sends
|
||||||
|
// every row and an existing cache gains its values.
|
||||||
|
// v9: + auth_session.normalizationJson, the loudness-normalization
|
||||||
|
// preference (M464 #4998). The first schema step with an explicit
|
||||||
|
// Migration (MIGRATION_8_9): a destructive rebuild would also wipe this
|
||||||
|
// row — the server address and theme — and the queued offline writes,
|
||||||
|
// which is too much to lose for one added column.
|
||||||
// v8: + cached_tracks.missing, the server's missing-file mark (#2704),
|
// v8: + cached_tracks.missing, the server's missing-file mark (#2704),
|
||||||
// so cache-first surfaces stop offering files that cannot stream.
|
// so cache-first surfaces stop offering files that cannot stream.
|
||||||
// v7: + diagnostic_events table (M9) and the diagnosticsOptOut column
|
// v7: + diagnostic_events table (M9) and the diagnosticsOptOut column
|
||||||
// on auth_session. Pre-v1 destructive fallback rebuilds on mismatch —
|
// on auth_session. Pre-v1 destructive fallback rebuilds on mismatch —
|
||||||
// which is exactly right here: the next sync refills every row with the
|
// which is exactly right here: the next sync refills every row with the
|
||||||
// new column populated, so there is nothing to migrate by hand.
|
// new column populated, so there is nothing to migrate by hand.
|
||||||
version = 8,
|
version = 10,
|
||||||
exportSchema = true,
|
exportSchema = true,
|
||||||
)
|
)
|
||||||
@TypeConverters(MinstrelTypeConverters::class)
|
@TypeConverters(MinstrelTypeConverters::class)
|
||||||
@@ -92,3 +103,25 @@ abstract class AppDatabase : RoomDatabase() {
|
|||||||
abstract fun authSessionDao(): AuthSessionDao
|
abstract fun authSessionDao(): AuthSessionDao
|
||||||
abstract fun diagnosticEventDao(): DiagnosticEventDao
|
abstract fun diagnosticEventDao(): DiagnosticEventDao
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** v8 → v9: add the nullable normalization preference column (#4998). */
|
||||||
|
val MIGRATION_8_9: Migration = object : Migration(8, 9) {
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
db.execSQL("ALTER TABLE auth_session ADD COLUMN normalizationJson TEXT")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v9 → v10: the gain columns (#5000). Rows synced before this carry no gains,
|
||||||
|
* and the sync is incremental, so it would never re-send them: the cursor goes
|
||||||
|
* back to 0 and the next sync is a full one, upserting every row in place.
|
||||||
|
*/
|
||||||
|
val MIGRATION_9_10: Migration = object : Migration(9, 10) {
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
db.execSQL("ALTER TABLE cached_tracks ADD COLUMN trackGain REAL")
|
||||||
|
db.execSQL("ALTER TABLE cached_tracks ADD COLUMN trackPeak REAL")
|
||||||
|
db.execSQL("ALTER TABLE cached_albums ADD COLUMN albumGain REAL")
|
||||||
|
db.execSQL("ALTER TABLE cached_albums ADD COLUMN albumPeak REAL")
|
||||||
|
db.execSQL("UPDATE sync_metadata SET cursor = 0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ object DatabaseModule {
|
|||||||
// launch, so users lose only the unsynced mutation queue
|
// launch, so users lose only the unsynced mutation queue
|
||||||
// (acceptable while we're iterating). Replace with explicit
|
// (acceptable while we're iterating). Replace with explicit
|
||||||
// Migration entries before the first tagged release.
|
// Migration entries before the first tagged release.
|
||||||
|
.addMigrations(MIGRATION_8_9, MIGRATION_9_10)
|
||||||
.fallbackToDestructiveMigration(dropAllTables = true)
|
.fallbackToDestructiveMigration(dropAllTables = true)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
|
|||||||
+4
@@ -46,4 +46,8 @@ interface AuthSessionDao {
|
|||||||
/** Partial update: change only the per-device diagnostics opt-out. */
|
/** Partial update: change only the per-device diagnostics opt-out. */
|
||||||
@Query("UPDATE auth_session SET diagnosticsOptOut = :optOut WHERE id = 0")
|
@Query("UPDATE auth_session SET diagnosticsOptOut = :optOut WHERE id = 0")
|
||||||
suspend fun setDiagnosticsOptOut(optOut: Boolean)
|
suspend fun setDiagnosticsOptOut(optOut: Boolean)
|
||||||
|
|
||||||
|
/** Partial update: change only the serialized normalization preference. */
|
||||||
|
@Query("UPDATE auth_session SET normalizationJson = :json WHERE id = 0")
|
||||||
|
suspend fun setNormalizationJson(json: String?)
|
||||||
}
|
}
|
||||||
|
|||||||
+4
@@ -31,4 +31,8 @@ interface CachedMutationDao {
|
|||||||
|
|
||||||
@Query("DELETE FROM cached_mutations")
|
@Query("DELETE FROM cached_mutations")
|
||||||
suspend fun clear()
|
suspend fun clear()
|
||||||
|
|
||||||
|
/** Whether a write of [kind] is still waiting to be replayed. */
|
||||||
|
@Query("SELECT EXISTS(SELECT 1 FROM cached_mutations WHERE kind = :kind)")
|
||||||
|
suspend fun hasPending(kind: String): Boolean
|
||||||
}
|
}
|
||||||
|
|||||||
+21
@@ -38,6 +38,27 @@ interface CachedTrackDao {
|
|||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
suspend fun upsertAll(rows: List<CachedTrackEntity>)
|
suspend fun upsertAll(rows: List<CachedTrackEntity>)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ReplayGain values for [ids] (M464 #5000): the track's own from its row,
|
||||||
|
* the album's from its album row. A track not in the cache has no row.
|
||||||
|
*/
|
||||||
|
@Query(
|
||||||
|
"SELECT t.id AS id, t.trackGain AS trackGain, t.trackPeak AS trackPeak, " +
|
||||||
|
"a.albumGain AS albumGain, a.albumPeak AS albumPeak " +
|
||||||
|
"FROM cached_tracks t LEFT JOIN cached_albums a ON a.id = t.albumId " +
|
||||||
|
"WHERE t.id IN (:ids)",
|
||||||
|
)
|
||||||
|
suspend fun replayGains(ids: List<String>): List<CachedReplayGain>
|
||||||
|
|
||||||
@Query("DELETE FROM cached_tracks WHERE id IN (:ids)")
|
@Query("DELETE FROM cached_tracks WHERE id IN (:ids)")
|
||||||
suspend fun deleteByIds(ids: List<String>)
|
suspend fun deleteByIds(ids: List<String>)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One row of [CachedTrackDao.replayGains]. */
|
||||||
|
data class CachedReplayGain(
|
||||||
|
val id: String,
|
||||||
|
val trackGain: Float?,
|
||||||
|
val trackPeak: Float?,
|
||||||
|
val albumGain: Float?,
|
||||||
|
val albumPeak: Float?,
|
||||||
|
)
|
||||||
|
|||||||
+6
@@ -43,4 +43,10 @@ data class AuthSessionEntity(
|
|||||||
* choice lives here. Default false = honor the account flag.
|
* choice lives here. Default false = honor the account flag.
|
||||||
*/
|
*/
|
||||||
val diagnosticsOptOut: Boolean = false,
|
val diagnosticsOptOut: Boolean = false,
|
||||||
|
/**
|
||||||
|
* JSON-encoded NormalizationPrefs (settings/data), the last value seen
|
||||||
|
* from the server or set here (M464 #4998). Null = never fetched; the
|
||||||
|
* defaults apply. Kept so offline playback still levels.
|
||||||
|
*/
|
||||||
|
val normalizationJson: String? = null,
|
||||||
)
|
)
|
||||||
|
|||||||
+3
@@ -18,5 +18,8 @@ data class CachedAlbumEntity(
|
|||||||
val releaseDate: String? = null,
|
val releaseDate: String? = null,
|
||||||
val coverPath: String? = null,
|
val coverPath: String? = null,
|
||||||
val mbid: String? = null,
|
val mbid: String? = null,
|
||||||
|
// ReplayGain 2.0 album values (M464); null until every track is measured.
|
||||||
|
val albumGain: Float? = null,
|
||||||
|
val albumPeak: Float? = null,
|
||||||
val fetchedAt: Instant = Clock.System.now(),
|
val fetchedAt: Instant = Clock.System.now(),
|
||||||
)
|
)
|
||||||
|
|||||||
+4
@@ -26,5 +26,9 @@ data class CachedTrackEntity(
|
|||||||
val fileFormat: String? = null,
|
val fileFormat: String? = null,
|
||||||
val genre: String? = null,
|
val genre: String? = null,
|
||||||
val missing: Boolean = false,
|
val missing: Boolean = false,
|
||||||
|
// ReplayGain 2.0 track values (M464), kept so cached audio levels
|
||||||
|
// offline. Null until the server has measured the track.
|
||||||
|
val trackGain: Float? = null,
|
||||||
|
val trackPeak: Float? = null,
|
||||||
val fetchedAt: Instant = Clock.System.now(),
|
val fetchedAt: Instant = Clock.System.now(),
|
||||||
)
|
)
|
||||||
|
|||||||
+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.dao.CachedMutationDao
|
||||||
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
|
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
|
||||||
|
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
|
||||||
import kotlinx.coroutines.channels.BufferOverflow
|
import kotlinx.coroutines.channels.BufferOverflow
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.SharedFlow
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
@@ -41,6 +42,11 @@ object MutationKind {
|
|||||||
// an undo collapses to the latest intent instead of replaying as two
|
// an undo collapses to the latest intent instead of replaying as two
|
||||||
// opposed calls whose order decides the outcome.
|
// opposed calls whose order decides the outcome.
|
||||||
const val SUGGESTION_SNOOZE_TOGGLE: String = "suggestion_snooze_toggle"
|
const val SUGGESTION_SNOOZE_TOGGLE: String = "suggestion_snooze_toggle"
|
||||||
|
|
||||||
|
// M464 #4998 loudness-normalization preference. The payload is the whole
|
||||||
|
// preference, a target state like the toggles above, so queued changes
|
||||||
|
// collapse to the last one and an older one can never be replayed last.
|
||||||
|
const val NORMALIZATION_SET: String = "normalization_set"
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -85,6 +91,7 @@ data class RequestCreatePayload(
|
|||||||
* This matches `feedback_offline_first_for_server_writes` — writes
|
* This matches `feedback_offline_first_for_server_writes` — writes
|
||||||
* never go fire-and-forget.
|
* never go fire-and-forget.
|
||||||
*/
|
*/
|
||||||
|
@Suppress("TooManyFunctions") // one enqueue per mutation kind, like the replayer's dispatchers
|
||||||
@Singleton
|
@Singleton
|
||||||
class MutationQueue @Inject constructor(
|
class MutationQueue @Inject constructor(
|
||||||
private val dao: CachedMutationDao,
|
private val dao: CachedMutationDao,
|
||||||
@@ -177,6 +184,12 @@ class MutationQueue @Inject constructor(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** Queues the user's whole normalization preference for replay. */
|
||||||
|
suspend fun enqueueNormalizationSet(prefs: NormalizationPrefs): Long = insertUserDriven(
|
||||||
|
MutationKind.NORMALIZATION_SET,
|
||||||
|
json.encodeToString(NormalizationPrefs.serializer(), prefs),
|
||||||
|
)
|
||||||
|
|
||||||
suspend fun enqueueRequestCancel(requestId: String): Long = insertUserDriven(
|
suspend fun enqueueRequestCancel(requestId: String): Long = insertUserDriven(
|
||||||
MutationKind.REQUEST_CANCEL,
|
MutationKind.REQUEST_CANCEL,
|
||||||
json.encodeToString(
|
json.encodeToString(
|
||||||
|
|||||||
+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.EventsApi
|
||||||
import com.fabledsword.minstrel.api.endpoints.FlagRequest
|
import com.fabledsword.minstrel.api.endpoints.FlagRequest
|
||||||
import com.fabledsword.minstrel.api.endpoints.LikesApi
|
import com.fabledsword.minstrel.api.endpoints.LikesApi
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.MeApi
|
||||||
import com.fabledsword.minstrel.api.endpoints.PlaybackErrorReportRequest
|
import com.fabledsword.minstrel.api.endpoints.PlaybackErrorReportRequest
|
||||||
import com.fabledsword.minstrel.api.endpoints.PlaybackErrorsApi
|
import com.fabledsword.minstrel.api.endpoints.PlaybackErrorsApi
|
||||||
import com.fabledsword.minstrel.api.endpoints.PlaylistsApi
|
import com.fabledsword.minstrel.api.endpoints.PlaylistsApi
|
||||||
@@ -22,6 +23,7 @@ import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
|
|||||||
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
|
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
|
||||||
import com.fabledsword.minstrel.di.ApplicationScope
|
import com.fabledsword.minstrel.di.ApplicationScope
|
||||||
import com.fabledsword.minstrel.likes.data.LikesRepository
|
import com.fabledsword.minstrel.likes.data.LikesRepository
|
||||||
|
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
|
||||||
import com.fabledsword.minstrel.models.wire.CreateRequestBody
|
import com.fabledsword.minstrel.models.wire.CreateRequestBody
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
@@ -79,6 +81,7 @@ class MutationReplayer @Inject constructor(
|
|||||||
private val eventsApi: EventsApi = retrofit.create()
|
private val eventsApi: EventsApi = retrofit.create()
|
||||||
private val requestsApi: RequestsApi = retrofit.create()
|
private val requestsApi: RequestsApi = retrofit.create()
|
||||||
private val playbackErrorsApi: PlaybackErrorsApi = retrofit.create()
|
private val playbackErrorsApi: PlaybackErrorsApi = retrofit.create()
|
||||||
|
private val meApi: MeApi = retrofit.create()
|
||||||
|
|
||||||
private val mutex = Mutex()
|
private val mutex = Mutex()
|
||||||
|
|
||||||
@@ -166,6 +169,7 @@ class MutationReplayer @Inject constructor(
|
|||||||
MutationKind.REQUEST_CANCEL -> dispatchRequestCancel(row.payload)
|
MutationKind.REQUEST_CANCEL -> dispatchRequestCancel(row.payload)
|
||||||
MutationKind.PLAYBACK_ERROR_REPORT -> dispatchPlaybackErrorReport(row.payload)
|
MutationKind.PLAYBACK_ERROR_REPORT -> dispatchPlaybackErrorReport(row.payload)
|
||||||
MutationKind.SUGGESTION_SNOOZE_TOGGLE -> dispatchSuggestionSnoozeToggle(row.payload)
|
MutationKind.SUGGESTION_SNOOZE_TOGGLE -> dispatchSuggestionSnoozeToggle(row.payload)
|
||||||
|
MutationKind.NORMALIZATION_SET -> dispatchNormalizationSet(row.payload)
|
||||||
// Unknown kind — drop so a stale schema entry can't wedge the queue.
|
// Unknown kind — drop so a stale schema entry can't wedge the queue.
|
||||||
else -> Outcome.DROP
|
else -> Outcome.DROP
|
||||||
}
|
}
|
||||||
@@ -279,6 +283,16 @@ class MutationReplayer @Inject constructor(
|
|||||||
return Outcome.SENT
|
return Outcome.SENT
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends the queued normalization preference. The device already shows
|
||||||
|
* it, so the server's echo is not written back: a change made since the
|
||||||
|
* row was queued would be a newer row, and the collapse keeps only that.
|
||||||
|
*/
|
||||||
|
private suspend fun dispatchNormalizationSet(payload: String): Outcome {
|
||||||
|
meApi.putNormalization(json.decodeFromString(NormalizationPrefs.serializer(), payload))
|
||||||
|
return Outcome.SENT
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun dispatchPlaybackErrorReport(payload: String): Outcome {
|
private suspend fun dispatchPlaybackErrorReport(payload: String): Outcome {
|
||||||
val decoded = json.decodeFromString(PlaybackErrorReportPayload.serializer(), payload)
|
val decoded = json.decodeFromString(PlaybackErrorReportPayload.serializer(), payload)
|
||||||
playbackErrorsApi.report(
|
playbackErrorsApi.report(
|
||||||
@@ -303,7 +317,8 @@ class MutationReplayer @Inject constructor(
|
|||||||
/**
|
/**
|
||||||
* Row ids of desired-state toggles superseded by a later toggle for the same
|
* Row ids of desired-state toggles superseded by a later toggle for the same
|
||||||
* entity. Applies to every kind whose payload encodes a TARGET state rather
|
* entity. Applies to every kind whose payload encodes a TARGET state rather
|
||||||
* than an action — like-toggles and suggestion snoozes (#2374) — because
|
* than an action — like-toggles, suggestion snoozes (#2374) and the
|
||||||
|
* normalization preference (#4998) — because
|
||||||
* replaying a stale one last would invert the final state.
|
* replaying a stale one last would invert the final state.
|
||||||
*
|
*
|
||||||
* Top-level and pure so it can be unit-tested without standing up a Retrofit
|
* Top-level and pure so it can be unit-tested without standing up a Retrofit
|
||||||
@@ -340,5 +355,10 @@ private fun toggleKeyOf(row: CachedMutationEntity, json: Json): String? = when (
|
|||||||
json.decodeFromString(SuggestionSnoozeTogglePayload.serializer(), row.payload)
|
json.decodeFromString(SuggestionSnoozeTogglePayload.serializer(), row.payload)
|
||||||
}.getOrNull()?.let { "${row.kind}:${it.mbid}" }
|
}.getOrNull()?.let { "${row.kind}:${it.mbid}" }
|
||||||
|
|
||||||
|
// One preference per user, so every normalization row shares one key.
|
||||||
|
MutationKind.NORMALIZATION_SET -> runCatching {
|
||||||
|
json.decodeFromString(NormalizationPrefs.serializer(), row.payload)
|
||||||
|
}.getOrNull()?.let { row.kind }
|
||||||
|
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
|
|||||||
+4
@@ -206,6 +206,8 @@ private fun SyncAlbumWire.toEntity(): CachedAlbumEntity = CachedAlbumEntity(
|
|||||||
releaseDate = releaseDate,
|
releaseDate = releaseDate,
|
||||||
coverPath = coverArtPath,
|
coverPath = coverArtPath,
|
||||||
mbid = mbid,
|
mbid = mbid,
|
||||||
|
albumGain = albumGain,
|
||||||
|
albumPeak = albumPeak,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun SyncTrackWire.toEntity(): CachedTrackEntity = CachedTrackEntity(
|
private fun SyncTrackWire.toEntity(): CachedTrackEntity = CachedTrackEntity(
|
||||||
@@ -220,4 +222,6 @@ private fun SyncTrackWire.toEntity(): CachedTrackEntity = CachedTrackEntity(
|
|||||||
fileFormat = fileFormat,
|
fileFormat = fileFormat,
|
||||||
genre = genre,
|
genre = genre,
|
||||||
missing = missing,
|
missing = missing,
|
||||||
|
trackGain = trackGain,
|
||||||
|
trackPeak = trackPeak,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.fabledsword.minstrel.models.wire
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/** One track's entry in `GET /api/tracks/replay-gain` (#4997). Decode-only. */
|
||||||
|
@Serializable
|
||||||
|
data class ReplayGainWire(
|
||||||
|
@SerialName("track_gain") val trackGain: Float? = null,
|
||||||
|
@SerialName("track_peak") val trackPeak: Float? = null,
|
||||||
|
@SerialName("album_gain") val albumGain: Float? = null,
|
||||||
|
@SerialName("album_peak") val albumPeak: Float? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ReplayGainResponseWire(
|
||||||
|
val items: Map<String, ReplayGainWire> = emptyMap(),
|
||||||
|
)
|
||||||
@@ -28,6 +28,10 @@ data class SyncAlbumWire(
|
|||||||
@SerialName("release_date") val releaseDate: String? = null,
|
@SerialName("release_date") val releaseDate: String? = null,
|
||||||
@SerialName("cover_art_path") val coverArtPath: String? = null,
|
@SerialName("cover_art_path") val coverArtPath: String? = null,
|
||||||
val mbid: String? = null,
|
val mbid: String? = null,
|
||||||
|
// The album's ReplayGain 2.0 values (#4997): dB to -18 LUFS and a linear
|
||||||
|
// peak. Null until every track on the album is measured.
|
||||||
|
@SerialName("album_gain") val albumGain: Float? = null,
|
||||||
|
@SerialName("album_peak") val albumPeak: Float? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -51,6 +55,9 @@ data class SyncTrackWire(
|
|||||||
// its tracks stay playable, which is the correct reading of "this server
|
// its tracks stay playable, which is the correct reading of "this server
|
||||||
// has nothing to say about missing files".
|
// has nothing to say about missing files".
|
||||||
val missing: Boolean = false,
|
val missing: Boolean = false,
|
||||||
|
// The track's ReplayGain 2.0 values (#4997); null until it is measured.
|
||||||
|
@SerialName("track_gain") val trackGain: Float? = null,
|
||||||
|
@SerialName("track_peak") val trackPeak: Float? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -93,6 +93,8 @@ class MinstrelForwardingPlayer(
|
|||||||
* one. Diagnostics-only; see [TransportObservation].
|
* one. Diagnostics-only; see [TransportObservation].
|
||||||
*/
|
*/
|
||||||
val onTransport: (TransportObservation) -> Unit = {},
|
val onTransport: (TransportObservation) -> Unit = {},
|
||||||
|
/** The renderer's 1-based queue position, every poll. */
|
||||||
|
val onRendererTrack: (trackNumber: Int) -> Unit = {},
|
||||||
)
|
)
|
||||||
|
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
@@ -622,6 +624,7 @@ class MinstrelForwardingPlayer(
|
|||||||
trackNumber = info.track,
|
trackNumber = info.track,
|
||||||
)
|
)
|
||||||
syncLocalCursorToRemote(sonosTrack = info.track, trackUri = info.trackUri)
|
syncLocalCursorToRemote(sonosTrack = info.track, trackUri = info.trackUri)
|
||||||
|
events.onRendererTrack(info.track)
|
||||||
val transport = active.avTransport.getTransportInfo()
|
val transport = active.avTransport.getTransportInfo()
|
||||||
when (transport.state) {
|
when (transport.state) {
|
||||||
TransportState.PLAYING -> {
|
TransportState.PLAYING -> {
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ import androidx.media3.session.SessionCommand
|
|||||||
import com.fabledsword.minstrel.MainActivity
|
import com.fabledsword.minstrel.MainActivity
|
||||||
import com.fabledsword.minstrel.likes.data.LikesRepository
|
import com.fabledsword.minstrel.likes.data.LikesRepository
|
||||||
import com.fabledsword.minstrel.likes.data.LikesRepository.Companion.ENTITY_TRACK
|
import com.fabledsword.minstrel.likes.data.LikesRepository.Companion.ENTITY_TRACK
|
||||||
|
import com.fabledsword.minstrel.settings.data.NormalizationRepository
|
||||||
import com.google.common.collect.ImmutableList
|
import com.google.common.collect.ImmutableList
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
@@ -25,6 +27,7 @@ import kotlinx.coroutines.flow.flatMapLatest
|
|||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.flow.onStart
|
import kotlinx.coroutines.flow.onStart
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -66,6 +69,8 @@ class MinstrelPlayerService : MediaSessionService() {
|
|||||||
|
|
||||||
@Inject lateinit var likesRepository: LikesRepository
|
@Inject lateinit var likesRepository: LikesRepository
|
||||||
|
|
||||||
|
@Inject lateinit var normalizationRepository: NormalizationRepository
|
||||||
|
|
||||||
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||||
|
|
||||||
private var mediaSession: MediaSession? = null
|
private var mediaSession: MediaSession? = null
|
||||||
@@ -82,6 +87,21 @@ class MinstrelPlayerService : MediaSessionService() {
|
|||||||
.build()
|
.build()
|
||||||
mediaSession = session
|
mediaSession = session
|
||||||
serviceScope.launch { observeLikeState(session, player) }
|
serviceScope.launch { observeLikeState(session, player) }
|
||||||
|
serviceScope.launch { refreshNormalization() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Takes up a leveling preference changed on another device (M464 #5000).
|
||||||
|
// Offline, the device's copy stands, which is the one playback reads.
|
||||||
|
private suspend fun refreshNormalization() {
|
||||||
|
try {
|
||||||
|
normalizationRepository.refresh()
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (
|
||||||
|
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||||
|
) {
|
||||||
|
Timber.d(e, "normalization refresh skipped")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import androidx.media3.session.MediaController
|
|||||||
import androidx.media3.session.SessionToken
|
import androidx.media3.session.SessionToken
|
||||||
import com.fabledsword.minstrel.di.ApplicationScope
|
import com.fabledsword.minstrel.di.ApplicationScope
|
||||||
import com.fabledsword.minstrel.models.TrackRef
|
import com.fabledsword.minstrel.models.TrackRef
|
||||||
|
import com.fabledsword.minstrel.player.gain.GainAudioProcessor
|
||||||
|
import com.fabledsword.minstrel.player.gain.ReplayGainStore
|
||||||
import com.fabledsword.minstrel.playlists.data.PlaylistsRepository
|
import com.fabledsword.minstrel.playlists.data.PlaylistsRepository
|
||||||
import com.fabledsword.minstrel.playlists.data.toPlayableTrackRefs
|
import com.fabledsword.minstrel.playlists.data.toPlayableTrackRefs
|
||||||
import com.fabledsword.minstrel.shared.resolveServerUrl
|
import com.fabledsword.minstrel.shared.resolveServerUrl
|
||||||
@@ -69,6 +71,7 @@ class PlayerController @Inject constructor(
|
|||||||
private val playerFactory: PlayerFactory,
|
private val playerFactory: PlayerFactory,
|
||||||
private val activeUpnpHolder: com.fabledsword.minstrel.player.output.ActiveUpnpHolder,
|
private val activeUpnpHolder: com.fabledsword.minstrel.player.output.ActiveUpnpHolder,
|
||||||
private val remoteState: RemotePlayerState,
|
private val remoteState: RemotePlayerState,
|
||||||
|
private val replayGains: ReplayGainStore,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -277,6 +280,11 @@ class PlayerController @Inject constructor(
|
|||||||
queueRefs = playable.tracks
|
queueRefs = playable.tracks
|
||||||
val items = playable.tracks.map { it.toMediaItem(source) }
|
val items = playable.tracks.map { it.toMediaItem(source) }
|
||||||
val startIndex = playable.initialIndex
|
val startIndex = playable.initialIndex
|
||||||
|
// Gains for the first tracks, so the first one levels from its first
|
||||||
|
// sample rather than ramping in once the lookup lands.
|
||||||
|
replayGains.request(
|
||||||
|
playable.tracks.drop(startIndex).take(GAIN_PREFETCH).map { it.id },
|
||||||
|
)
|
||||||
// Drift #562 cold-boot resume calls this from a non-Main suspend
|
// Drift #562 cold-boot resume calls this from a non-Main suspend
|
||||||
// context after awaitReady() unblocks (ResumeController launches
|
// context after awaitReady() unblocks (ResumeController launches
|
||||||
// on Dispatchers.Default by the time it reaches us). MediaController
|
// on Dispatchers.Default by the time it reaches us). MediaController
|
||||||
@@ -792,7 +800,13 @@ class PlayerController @Inject constructor(
|
|||||||
// scrubber a real total even when the wrapped ExoPlayer is
|
// scrubber a real total even when the wrapped ExoPlayer is
|
||||||
// paused under UPnP (it never probes a duration in that state).
|
// paused under UPnP (it never probes a duration in that state).
|
||||||
if (durationSec > 0) setDurationMs(durationSec.toLong() * MS_PER_SECOND)
|
if (durationSec > 0) setDurationMs(durationSec.toLong() * MS_PER_SECOND)
|
||||||
if (source != null) setExtras(sourceExtras(source))
|
// Album and track position let the gain processor tell an
|
||||||
|
// album played in order from a mix (M464 #5000).
|
||||||
|
trackNumber?.let { setTrackNumber(it) }
|
||||||
|
discNumber?.let { setDiscNumber(it) }
|
||||||
|
val extras = GainAudioProcessor.albumExtras(albumId)
|
||||||
|
if (source != null) extras.putAll(sourceExtras(source))
|
||||||
|
setExtras(extras)
|
||||||
// Point the notification / lock-screen art at the SAME album
|
// Point the notification / lock-screen art at the SAME album
|
||||||
// cover the in-app surfaces use (TrackRef.coverUrl ->
|
// cover the in-app surfaces use (TrackRef.coverUrl ->
|
||||||
// /api/albums/{id}/cover). Without this, Media3 falls back to
|
// /api/albums/{id}/cover). Without this, Media3 falls back to
|
||||||
@@ -860,6 +874,7 @@ class PlayerController @Inject constructor(
|
|||||||
const val MINSTREL_SOURCE_KEY: String = "minstrel_source"
|
const val MINSTREL_SOURCE_KEY: String = "minstrel_source"
|
||||||
private const val MS_PER_SECOND = 1_000L
|
private const val MS_PER_SECOND = 1_000L
|
||||||
private const val MAX_INTERPOLATION_DRIFT_MS = 5_000L
|
private const val MAX_INTERPOLATION_DRIFT_MS = 5_000L
|
||||||
|
private const val GAIN_PREFETCH = 20
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import android.content.Context
|
|||||||
import androidx.media3.common.AudioAttributes
|
import androidx.media3.common.AudioAttributes
|
||||||
import androidx.media3.common.C
|
import androidx.media3.common.C
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
|
import androidx.media3.common.audio.AudioProcessor
|
||||||
import androidx.media3.common.util.BitmapLoader
|
import androidx.media3.common.util.BitmapLoader
|
||||||
import androidx.media3.database.StandaloneDatabaseProvider
|
import androidx.media3.database.StandaloneDatabaseProvider
|
||||||
import androidx.media3.datasource.DataSourceBitmapLoader
|
import androidx.media3.datasource.DataSourceBitmapLoader
|
||||||
@@ -12,11 +13,18 @@ import androidx.media3.datasource.cache.CacheDataSource
|
|||||||
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
|
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
|
||||||
import androidx.media3.datasource.cache.SimpleCache
|
import androidx.media3.datasource.cache.SimpleCache
|
||||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||||
|
import androidx.media3.exoplayer.DefaultRenderersFactory
|
||||||
import androidx.media3.exoplayer.ExoPlayer
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
|
import androidx.media3.exoplayer.audio.AudioSink
|
||||||
|
import androidx.media3.exoplayer.audio.DefaultAudioSink
|
||||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||||
import androidx.media3.session.CacheBitmapLoader
|
import androidx.media3.session.CacheBitmapLoader
|
||||||
|
import com.fabledsword.minstrel.auth.AuthStore
|
||||||
import com.fabledsword.minstrel.cache.audiocache.CacheConfig
|
import com.fabledsword.minstrel.cache.audiocache.CacheConfig
|
||||||
|
import com.fabledsword.minstrel.player.gain.GainAudioProcessor
|
||||||
|
import com.fabledsword.minstrel.player.gain.ReplayGainStore
|
||||||
import com.fabledsword.minstrel.player.output.ActiveUpnpHolder
|
import com.fabledsword.minstrel.player.output.ActiveUpnpHolder
|
||||||
|
import com.fabledsword.minstrel.player.output.SonosQueueLoader
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.channels.BufferOverflow
|
import kotlinx.coroutines.channels.BufferOverflow
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
@@ -56,6 +64,9 @@ class PlayerFactory @Inject constructor(
|
|||||||
private val activeUpnpHolder: ActiveUpnpHolder,
|
private val activeUpnpHolder: ActiveUpnpHolder,
|
||||||
private val remoteState: RemotePlayerState,
|
private val remoteState: RemotePlayerState,
|
||||||
private val serverHealth: com.fabledsword.minstrel.connectivity.NetworkStatusController,
|
private val serverHealth: com.fabledsword.minstrel.connectivity.NetworkStatusController,
|
||||||
|
private val authStore: AuthStore,
|
||||||
|
private val replayGains: ReplayGainStore,
|
||||||
|
private val sonosQueue: SonosQueueLoader,
|
||||||
) {
|
) {
|
||||||
private val cacheDir: File = File(context.cacheDir, "audio_cache").apply { mkdirs() }
|
private val cacheDir: File = File(context.cacheDir, "audio_cache").apply { mkdirs() }
|
||||||
|
|
||||||
@@ -120,6 +131,7 @@ class PlayerFactory @Inject constructor(
|
|||||||
onStalled = { trackId -> stallEventsInternal.tryEmit(trackId) },
|
onStalled = { trackId -> stallEventsInternal.tryEmit(trackId) },
|
||||||
onQueueTruncated = { queueRepairInternal.tryEmit(Unit) },
|
onQueueTruncated = { queueRepairInternal.tryEmit(Unit) },
|
||||||
onTransport = { transportInternal.tryEmit(it) },
|
onTransport = { transportInternal.tryEmit(it) },
|
||||||
|
onRendererTrack = { sonosQueue.onRendererTrack(it) },
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -142,7 +154,28 @@ class PlayerFactory @Inject constructor(
|
|||||||
val mediaSourceFactory = DefaultMediaSourceFactory(context)
|
val mediaSourceFactory = DefaultMediaSourceFactory(context)
|
||||||
.setDataSourceFactory(cacheDataSource)
|
.setDataSourceFactory(cacheDataSource)
|
||||||
|
|
||||||
return ExoPlayer.Builder(context)
|
// Loudness normalization (M464 #5000) runs inside the audio sink so
|
||||||
|
// each track's gain starts on its first sample. Audio offload would
|
||||||
|
// bypass the sink's processors; ExoPlayer leaves it off unless asked
|
||||||
|
// (TrackSelectionParameters.audioOffloadPreferences), and nothing here
|
||||||
|
// asks, so leveling always applies.
|
||||||
|
val gainProcessor = GainAudioProcessor(
|
||||||
|
prefs = { authStore.normalization.value },
|
||||||
|
store = replayGains,
|
||||||
|
)
|
||||||
|
val renderersFactory = object : DefaultRenderersFactory(context) {
|
||||||
|
override fun buildAudioSink(
|
||||||
|
context: Context,
|
||||||
|
enableFloatOutput: Boolean,
|
||||||
|
enableAudioOutputPlaybackParams: Boolean,
|
||||||
|
): AudioSink = DefaultAudioSink.Builder(context)
|
||||||
|
.setEnableFloatOutput(enableFloatOutput)
|
||||||
|
.setEnableAudioOutputPlaybackParameters(enableAudioOutputPlaybackParams)
|
||||||
|
.setAudioProcessors(arrayOf<AudioProcessor>(gainProcessor))
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
val exo = ExoPlayer.Builder(context, renderersFactory)
|
||||||
.setMediaSourceFactory(mediaSourceFactory)
|
.setMediaSourceFactory(mediaSourceFactory)
|
||||||
.setAudioAttributes(
|
.setAudioAttributes(
|
||||||
AudioAttributes.Builder()
|
AudioAttributes.Builder()
|
||||||
@@ -153,6 +186,17 @@ class PlayerFactory @Inject constructor(
|
|||||||
)
|
)
|
||||||
.setHandleAudioBecomingNoisy(true)
|
.setHandleAudioBecomingNoisy(true)
|
||||||
.build()
|
.build()
|
||||||
|
// The processor finds a track's neighbours in play order, which
|
||||||
|
// depends on shuffle mode.
|
||||||
|
gainProcessor.shuffleEnabled = exo.shuffleModeEnabled
|
||||||
|
exo.addListener(
|
||||||
|
object : Player.Listener {
|
||||||
|
override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) {
|
||||||
|
gainProcessor.shuffleEnabled = shuffleModeEnabled
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return exo
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -19,6 +19,13 @@ import javax.inject.Singleton
|
|||||||
class StreamTokenProvider @Inject constructor(retrofit: Retrofit) {
|
class StreamTokenProvider @Inject constructor(retrofit: Retrofit) {
|
||||||
private val api: CastApi = retrofit.create()
|
private val api: CastApi = retrofit.create()
|
||||||
|
|
||||||
suspend fun mint(trackId: String): StreamTokenResponse =
|
/**
|
||||||
api.streamToken(StreamTokenRequest(trackId = trackId))
|
* Mints a URL for a speaker, leveled when the user's setting calls for
|
||||||
|
* it (M464 #5002). The server answers with the plain stream when it does
|
||||||
|
* not, so every speaker URL asks.
|
||||||
|
*/
|
||||||
|
suspend fun mint(trackId: String, asAlbum: Boolean = false, prerender: Boolean = false): StreamTokenResponse =
|
||||||
|
api.streamToken(
|
||||||
|
StreamTokenRequest(trackId = trackId, level = true, asAlbum = asAlbum, prerender = prerender),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package com.fabledsword.minstrel.player.gain
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.media3.common.C
|
||||||
|
import androidx.media3.common.MediaItem
|
||||||
|
import androidx.media3.common.Player
|
||||||
|
import androidx.media3.common.Timeline
|
||||||
|
import androidx.media3.common.audio.AudioProcessor
|
||||||
|
import androidx.media3.common.audio.BaseAudioProcessor
|
||||||
|
import com.fabledsword.minstrel.settings.data.NormalizationBoost
|
||||||
|
import com.fabledsword.minstrel.settings.data.NormalizationMode
|
||||||
|
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies each track's loudness gain inside ExoPlayer's audio sink (M464
|
||||||
|
* #5000), so the level changes on the exact sample a track starts, gapless
|
||||||
|
* transitions included. `Player.setVolume` could do neither: it stops at 1,
|
||||||
|
* and set from a transition callback it lands about two seconds late
|
||||||
|
* because the next track is already buffered (androidx/media#418).
|
||||||
|
*
|
||||||
|
* Media3 1.11 flushes the sink's processors at every item boundary with the
|
||||||
|
* playlist [Timeline] and the new item's period, which is how this knows
|
||||||
|
* which track it is processing and who its neighbours are.
|
||||||
|
*
|
||||||
|
* Boosts above unity are held under -1 dBFS by a peak limiter when the user
|
||||||
|
* chose one. In headroom mode the gain already stops short of the track's
|
||||||
|
* true peak, and the final clamp only guards against a bad measurement.
|
||||||
|
*
|
||||||
|
* Runs on the playback thread. [prefs] and [store] are read per buffer, so a
|
||||||
|
* changed preference or a gain that arrives mid-track applies at once,
|
||||||
|
* through a short ramp rather than a step.
|
||||||
|
*/
|
||||||
|
class GainAudioProcessor(
|
||||||
|
private val prefs: () -> NormalizationPrefs,
|
||||||
|
private val store: ReplayGainStore,
|
||||||
|
) : BaseAudioProcessor() {
|
||||||
|
|
||||||
|
/** Mirrors the player's shuffle mode, so neighbours are found in play order. */
|
||||||
|
@Volatile var shuffleEnabled: Boolean = false
|
||||||
|
|
||||||
|
private var currentId: String? = null
|
||||||
|
private var asAlbum = false
|
||||||
|
private val stage = GainStage()
|
||||||
|
|
||||||
|
override fun onConfigure(inputAudioFormat: AudioProcessor.AudioFormat): AudioProcessor.AudioFormat =
|
||||||
|
when (inputAudioFormat.encoding) {
|
||||||
|
C.ENCODING_PCM_16BIT, C.ENCODING_PCM_FLOAT -> inputAudioFormat
|
||||||
|
else -> AudioProcessor.AudioFormat.NOT_SET
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onFlush(streamMetadata: AudioProcessor.StreamMetadata) {
|
||||||
|
identify(streamMetadata)
|
||||||
|
// A new stream starts at its own level: ramping in from the previous
|
||||||
|
// track's gain would swell or dip its first moments.
|
||||||
|
stage.reset(inputAudioFormat.sampleRate, inputAudioFormat.channelCount, targetGain())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onReset() {
|
||||||
|
currentId = null
|
||||||
|
asAlbum = false
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun queueInput(inputBuffer: ByteBuffer) {
|
||||||
|
val size = inputBuffer.remaining()
|
||||||
|
if (size == 0) return
|
||||||
|
val out = replaceOutputBuffer(size)
|
||||||
|
val p = prefs()
|
||||||
|
val target = targetGain(p)
|
||||||
|
val limiting = p.mode != NormalizationMode.OFF && p.boost == NormalizationBoost.LIMITER
|
||||||
|
if (stage.isUnity(target, limiting)) {
|
||||||
|
out.put(inputBuffer)
|
||||||
|
} else {
|
||||||
|
stage.process(
|
||||||
|
input = inputBuffer,
|
||||||
|
out = out,
|
||||||
|
channels = inputAudioFormat.channelCount,
|
||||||
|
isFloat = inputAudioFormat.encoding == C.ENCODING_PCM_FLOAT,
|
||||||
|
target = target,
|
||||||
|
limiting = limiting,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
out.flip()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun targetGain(p: NormalizationPrefs = prefs()): Float {
|
||||||
|
val id = currentId ?: return 1f
|
||||||
|
return GainMath.dbToLinear(GainMath.gainDb(p, store.get(id), asAlbum))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Which track this stream is, and whether it is playing as part of its
|
||||||
|
// album. Also asks the store for the gains of the tracks coming up, so
|
||||||
|
// each is known before it starts.
|
||||||
|
private fun identify(meta: AudioProcessor.StreamMetadata) {
|
||||||
|
currentId = null
|
||||||
|
asAlbum = false
|
||||||
|
val uid = meta.periodUid
|
||||||
|
if (uid == null || meta.timeline.isEmpty) return
|
||||||
|
val timeline = meta.timeline
|
||||||
|
val index = timeline.getPeriodByUid(uid, Timeline.Period()).windowIndex
|
||||||
|
val window = Timeline.Window()
|
||||||
|
fun itemAt(i: Int): MediaItem? =
|
||||||
|
if (i == C.INDEX_UNSET) null else timeline.getWindow(i, window).mediaItem
|
||||||
|
fun nextOf(i: Int) = timeline.getNextWindowIndex(i, Player.REPEAT_MODE_OFF, shuffleEnabled)
|
||||||
|
|
||||||
|
val cur = itemAt(index)
|
||||||
|
val prev = itemAt(timeline.getPreviousWindowIndex(index, Player.REPEAT_MODE_OFF, shuffleEnabled))
|
||||||
|
val next = itemAt(nextOf(index))
|
||||||
|
if (cur != null) {
|
||||||
|
currentId = cur.mediaId
|
||||||
|
asAlbum = GainMath.playingAsAlbum(prev?.albumPosition(), cur.albumPosition(), next?.albumPosition())
|
||||||
|
}
|
||||||
|
|
||||||
|
val upcoming = mutableListOf<String>()
|
||||||
|
var i = index
|
||||||
|
while (i != C.INDEX_UNSET && upcoming.size < LOOKAHEAD) {
|
||||||
|
itemAt(i)?.let { upcoming += it.mediaId }
|
||||||
|
i = nextOf(i)
|
||||||
|
}
|
||||||
|
store.request(upcoming)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** MediaMetadata extras key holding the track's album id. */
|
||||||
|
const val EXTRA_ALBUM_ID = "minstrel.album_id"
|
||||||
|
|
||||||
|
private const val LOOKAHEAD = 20
|
||||||
|
|
||||||
|
/** Puts what [albumPosition] reads into a MediaItem's metadata extras. */
|
||||||
|
fun albumExtras(albumId: String, into: Bundle = Bundle()): Bundle =
|
||||||
|
into.apply { putString(EXTRA_ALBUM_ID, albumId) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun MediaItem.albumPosition(): AlbumPosition = AlbumPosition(
|
||||||
|
albumId = mediaMetadata.extras?.getString(GainAudioProcessor.EXTRA_ALBUM_ID),
|
||||||
|
discNumber = mediaMetadata.discNumber,
|
||||||
|
trackNumber = mediaMetadata.trackNumber,
|
||||||
|
)
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package com.fabledsword.minstrel.player.gain
|
||||||
|
|
||||||
|
import com.fabledsword.minstrel.settings.data.NormalizationBoost
|
||||||
|
import com.fabledsword.minstrel.settings.data.NormalizationMode
|
||||||
|
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
|
||||||
|
import kotlin.math.log10
|
||||||
|
import kotlin.math.min
|
||||||
|
import kotlin.math.pow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One track's ReplayGain 2.0 values from the server (#4997): dB to the
|
||||||
|
* -18 LUFS reference, and linear true peaks. A null field has not been
|
||||||
|
* measured yet.
|
||||||
|
*/
|
||||||
|
data class ReplayGain(
|
||||||
|
val trackGain: Float?,
|
||||||
|
val trackPeak: Float?,
|
||||||
|
val albumGain: Float?,
|
||||||
|
val albumPeak: Float?,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
val NONE = ReplayGain(null, null, null, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where a queue item sits in its album, for the auto-mode album rule. */
|
||||||
|
data class AlbumPosition(val albumId: String?, val discNumber: Int?, val trackNumber: Int?)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loudness-normalization math (M464 #5000). The same rules as the web
|
||||||
|
* player's `web/src/lib/player/gain.ts`, so a track levels the same on
|
||||||
|
* every device.
|
||||||
|
*/
|
||||||
|
object GainMath {
|
||||||
|
private const val REFERENCE_LUFS = -18
|
||||||
|
|
||||||
|
/** Headroom mode raises a quiet track only until its true peak reaches this. */
|
||||||
|
const val PEAK_CEILING_DBTP = -1f
|
||||||
|
|
||||||
|
/**
|
||||||
|
* No track is raised more than this, whatever its measurement says: a
|
||||||
|
* near-silent track would otherwise come out as amplified noise.
|
||||||
|
*/
|
||||||
|
const val MAX_BOOST_DB = 12f
|
||||||
|
|
||||||
|
private const val DISC_STRIDE = 1000
|
||||||
|
|
||||||
|
// Amplitude decibels: 20 dB per factor of ten.
|
||||||
|
private const val DB_PER_DECADE = 20f
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The gain to apply, in dB. 0 when leveling is off or the track has not
|
||||||
|
* been measured: an unmeasured track plays as mastered.
|
||||||
|
*/
|
||||||
|
fun gainDb(prefs: NormalizationPrefs, g: ReplayGain?, asAlbum: Boolean): Float {
|
||||||
|
if (prefs.mode == NormalizationMode.OFF || g == null) return 0f
|
||||||
|
val wantAlbum = prefs.mode == NormalizationMode.ALBUM ||
|
||||||
|
(prefs.mode == NormalizationMode.AUTO && asAlbum)
|
||||||
|
// Album gain falls back to track gain while the album is still being
|
||||||
|
// measured; track gain never falls back to album gain.
|
||||||
|
val useAlbum = wantAlbum && g.albumGain != null
|
||||||
|
val gain = if (useAlbum) g.albumGain else g.trackGain
|
||||||
|
val peak = if (useAlbum) g.albumPeak else g.trackPeak
|
||||||
|
return gain?.let { leveled(prefs, it, peak) } ?: 0f
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun leveled(prefs: NormalizationPrefs, gain: Float, peak: Float?): Float {
|
||||||
|
var db = gain + (prefs.targetLufs - REFERENCE_LUFS)
|
||||||
|
if (prefs.boost == NormalizationBoost.HEADROOM && peak != null && peak > 0f) {
|
||||||
|
db = min(db, PEAK_CEILING_DBTP - DB_PER_DECADE * log10(peak))
|
||||||
|
}
|
||||||
|
return min(db, MAX_BOOST_DB)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the current item is being played as part of its album, in
|
||||||
|
* order: a neighbour in play order is from the same album and sits on
|
||||||
|
* the right side of it. That is when album gain keeps the album's own
|
||||||
|
* dynamics (a quiet intro stays quiet); anywhere else track gain levels
|
||||||
|
* the mix.
|
||||||
|
*/
|
||||||
|
fun playingAsAlbum(prev: AlbumPosition?, cur: AlbumPosition, next: AlbumPosition?): Boolean {
|
||||||
|
val curOrder = order(cur)
|
||||||
|
if (cur.albumId == null || curOrder == null) return false
|
||||||
|
val prevOrder = prev?.takeIf { it.albumId == cur.albumId }?.let { order(it) }
|
||||||
|
val nextOrder = next?.takeIf { it.albumId == cur.albumId }?.let { order(it) }
|
||||||
|
return (prevOrder != null && prevOrder < curOrder) || (nextOrder != null && nextOrder > curOrder)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disc-major track order. A track with no number has no place in the
|
||||||
|
// order and is never evidence of album play.
|
||||||
|
private fun order(p: AlbumPosition): Int? =
|
||||||
|
p.trackNumber?.let { (p.discNumber ?: 1) * DISC_STRIDE + it }
|
||||||
|
|
||||||
|
fun dbToLinear(db: Float): Float = 10f.pow(db / DB_PER_DECADE)
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package com.fabledsword.minstrel.player.gain
|
||||||
|
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.exp
|
||||||
|
import kotlin.math.max
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The sample arithmetic of [GainAudioProcessor], apart from Media3 so it can
|
||||||
|
* be tested on the JVM: a gain that ramps toward its target, then an
|
||||||
|
* optional peak limiter, then a clamp to full scale. Interleaved 16-bit or
|
||||||
|
* float PCM in, the same format out.
|
||||||
|
*/
|
||||||
|
internal class GainStage {
|
||||||
|
private var gain = 1f
|
||||||
|
private var envelope = 0f
|
||||||
|
private var rampCoeff = 1f
|
||||||
|
private var releaseCoeff = 0f
|
||||||
|
private var frame = FloatArray(2)
|
||||||
|
|
||||||
|
/** Sets the sample rate and channel count, and starts the gain at [startGain]. */
|
||||||
|
fun reset(sampleRate: Int, channels: Int, startGain: Float) {
|
||||||
|
val rate = sampleRate.coerceAtLeast(1).toFloat()
|
||||||
|
rampCoeff = 1f - exp(-1f / (RAMP_SECONDS * rate))
|
||||||
|
releaseCoeff = exp(-1f / (RELEASE_SECONDS * rate))
|
||||||
|
if (frame.size < channels) frame = FloatArray(channels)
|
||||||
|
gain = startGain
|
||||||
|
envelope = 0f
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when processing would copy the input unchanged. */
|
||||||
|
fun isUnity(target: Float, limiting: Boolean): Boolean = gain == 1f && target == 1f && !limiting
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes every whole frame of [input] into [out]. A trailing partial
|
||||||
|
* frame is dropped, as Media3's own processors do.
|
||||||
|
*/
|
||||||
|
@Suppress("LongParameterList") // the PCM layout is four facts; a holder type would only rename them
|
||||||
|
fun process(
|
||||||
|
input: ByteBuffer,
|
||||||
|
out: ByteBuffer,
|
||||||
|
channels: Int,
|
||||||
|
isFloat: Boolean,
|
||||||
|
target: Float,
|
||||||
|
limiting: Boolean,
|
||||||
|
) {
|
||||||
|
val bytesPerFrame = channels * if (isFloat) FLOAT_BYTES else PCM16_BYTES
|
||||||
|
val frames = input.remaining() / bytesPerFrame
|
||||||
|
repeat(frames) {
|
||||||
|
gain += (target - gain) * rampCoeff
|
||||||
|
var peak = 0f
|
||||||
|
for (c in 0 until channels) {
|
||||||
|
val s = (if (isFloat) input.float else input.short / PCM16_SCALE) * gain
|
||||||
|
frame[c] = s
|
||||||
|
peak = max(peak, abs(s))
|
||||||
|
}
|
||||||
|
var reduce = 1f
|
||||||
|
if (limiting) {
|
||||||
|
envelope = max(peak, envelope * releaseCoeff)
|
||||||
|
if (envelope > LIMIT_CEILING) reduce = LIMIT_CEILING / envelope
|
||||||
|
}
|
||||||
|
for (c in 0 until channels) {
|
||||||
|
val v = (frame[c] * reduce).coerceIn(-1f, 1f)
|
||||||
|
if (isFloat) out.putFloat(v) else out.putShort((v * PCM16_MAX).roundToInt().toShort())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
input.position(input.limit())
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** -1 dBFS, the ceiling the web player's limiter holds too. */
|
||||||
|
const val LIMIT_CEILING = 0.8913f
|
||||||
|
|
||||||
|
private const val RAMP_SECONDS = 0.05f
|
||||||
|
private const val RELEASE_SECONDS = 0.25f
|
||||||
|
private const val PCM16_SCALE = 32768f
|
||||||
|
private const val PCM16_MAX = 32767f
|
||||||
|
private const val PCM16_BYTES = 2
|
||||||
|
private const val FLOAT_BYTES = 4
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
package com.fabledsword.minstrel.player.gain
|
||||||
|
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.ReplayGainApi
|
||||||
|
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
|
||||||
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
|
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||||
|
import com.fabledsword.minstrel.di.ApplicationScope
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
import retrofit2.Retrofit
|
||||||
|
import retrofit2.create
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The gains the player levels by (M464 #5000), looked up per track and
|
||||||
|
* held for the life of the process. The audio thread reads them with [get];
|
||||||
|
* [request] fills them in the background.
|
||||||
|
*
|
||||||
|
* Sources, most to least preferred:
|
||||||
|
* 1. the library cache, synced with the gains, so cached audio levels
|
||||||
|
* offline;
|
||||||
|
* 2. the server's replay-gain lookup, for a track not cached yet or
|
||||||
|
* measured since the last sync;
|
||||||
|
* 3. none: the track plays as mastered, and the player picks the gain up
|
||||||
|
* the moment it lands.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class ReplayGainStore internal constructor(
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
private val trackDao: CachedTrackDao,
|
||||||
|
private val api: ReplayGainApi,
|
||||||
|
private val serverHealth: () -> ServerHealth,
|
||||||
|
private val clock: () -> Long,
|
||||||
|
) {
|
||||||
|
@Inject constructor(
|
||||||
|
@ApplicationScope scope: CoroutineScope,
|
||||||
|
trackDao: CachedTrackDao,
|
||||||
|
retrofit: Retrofit,
|
||||||
|
network: NetworkStatusController,
|
||||||
|
) : this(
|
||||||
|
scope = scope,
|
||||||
|
trackDao = trackDao,
|
||||||
|
api = retrofit.create(),
|
||||||
|
serverHealth = { network.state.value },
|
||||||
|
clock = System::currentTimeMillis,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val gains = ConcurrentHashMap<String, ReplayGain>()
|
||||||
|
private val inflight: MutableSet<String> = ConcurrentHashMap.newKeySet()
|
||||||
|
|
||||||
|
// When the server last said it had nothing for a track. Not cached for
|
||||||
|
// good: the backfill may measure it minutes later.
|
||||||
|
private val missedAt = ConcurrentHashMap<String, Long>()
|
||||||
|
|
||||||
|
/** The track's gains, or null while they are unknown. Safe from any thread. */
|
||||||
|
fun get(trackId: String): ReplayGain? = gains[trackId]
|
||||||
|
|
||||||
|
/** Starts loading gains for any of [trackIds] not already known or loading. */
|
||||||
|
fun request(trackIds: Collection<String>) {
|
||||||
|
val now = clock()
|
||||||
|
val wanted = trackIds.filter { id ->
|
||||||
|
!gains.containsKey(id) &&
|
||||||
|
(missedAt[id]?.let { now - it > MISS_RETRY_MS } ?: true) &&
|
||||||
|
inflight.add(id)
|
||||||
|
}
|
||||||
|
if (wanted.isEmpty()) return
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
load(wanted)
|
||||||
|
} finally {
|
||||||
|
inflight.removeAll(wanted.toSet())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal suspend fun load(ids: List<String>) {
|
||||||
|
fromCache(ids)
|
||||||
|
val rest = ids.filter { !gains.containsKey(it) }
|
||||||
|
val health = serverHealth()
|
||||||
|
if (rest.isEmpty() || health == ServerHealth.Offline || health == ServerHealth.ServerDown) return
|
||||||
|
for (batch in rest.chunked(MAX_IDS_PER_REQUEST)) {
|
||||||
|
// A failed lookup leaves the batch unknown; the next request asks again.
|
||||||
|
if (!fromServer(batch)) return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun fromCache(ids: List<String>) {
|
||||||
|
val rows = try {
|
||||||
|
trackDao.replayGains(ids)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (
|
||||||
|
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||||
|
) {
|
||||||
|
Timber.w(e, "replay gain: cache read failed")
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
for (row in rows) {
|
||||||
|
// A cached row without a track gain is not an answer: the server
|
||||||
|
// may have measured the track since this device last synced.
|
||||||
|
if (row.trackGain == null) continue
|
||||||
|
gains[row.id] = ReplayGain(row.trackGain, row.trackPeak, row.albumGain, row.albumPeak)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Asks the server about [batch]; false when it could not be asked. */
|
||||||
|
private suspend fun fromServer(batch: List<String>): Boolean {
|
||||||
|
val res = try {
|
||||||
|
withTimeoutOrNull(REQUEST_TIMEOUT_MS) { api.getReplayGain(batch.joinToString(",")) }
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (
|
||||||
|
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||||
|
) {
|
||||||
|
Timber.w(e, "replay gain: lookup failed")
|
||||||
|
null
|
||||||
|
} ?: return false
|
||||||
|
val now = clock()
|
||||||
|
for (id in batch) {
|
||||||
|
val w = res.items[id]
|
||||||
|
if (w == null) {
|
||||||
|
missedAt[id] = now
|
||||||
|
} else {
|
||||||
|
gains[id] = ReplayGain(w.trackGain, w.trackPeak, w.albumGain, w.albumPeak)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val MAX_IDS_PER_REQUEST = 200 // the endpoint's limit
|
||||||
|
const val REQUEST_TIMEOUT_MS = 10_000L
|
||||||
|
const val MISS_RETRY_MS = 5 * 60_000L
|
||||||
|
}
|
||||||
|
}
|
||||||
+74
-22
@@ -4,6 +4,8 @@ import com.fabledsword.minstrel.di.ApplicationScope
|
|||||||
import com.fabledsword.minstrel.models.TrackRef
|
import com.fabledsword.minstrel.models.TrackRef
|
||||||
import com.fabledsword.minstrel.player.RemotePlayerState
|
import com.fabledsword.minstrel.player.RemotePlayerState
|
||||||
import com.fabledsword.minstrel.player.StreamTokenProvider
|
import com.fabledsword.minstrel.player.StreamTokenProvider
|
||||||
|
import com.fabledsword.minstrel.player.gain.AlbumPosition
|
||||||
|
import com.fabledsword.minstrel.player.gain.GainMath
|
||||||
import com.fabledsword.minstrel.player.output.upnp.AVTransportClient
|
import com.fabledsword.minstrel.player.output.upnp.AVTransportClient
|
||||||
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
|
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
|
||||||
import com.fabledsword.minstrel.player.output.upnp.bareUdn
|
import com.fabledsword.minstrel.player.output.upnp.bareUdn
|
||||||
@@ -24,6 +26,11 @@ import javax.inject.Singleton
|
|||||||
* with its own failure modes, and it had grown large enough to hide one:
|
* with its own failure modes, and it had grown large enough to hide one:
|
||||||
* every write here is a SOAP call that can fail individually, and until
|
* every write here is a SOAP call that can fail individually, and until
|
||||||
* [verifyQueueLength] nothing ever read the result back.
|
* [verifyQueueLength] nothing ever read the result back.
|
||||||
|
*
|
||||||
|
* Every URL sent is a leveled one (M464 #5002): the server renders the track
|
||||||
|
* at the user's loudness gain, or hands back the plain stream when leveling
|
||||||
|
* is off. Renders are slow enough to matter, so the track playing and the
|
||||||
|
* one after it are rendered ahead; the rest render when the speaker asks.
|
||||||
*/
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class SonosQueueLoader @Inject constructor(
|
class SonosQueueLoader @Inject constructor(
|
||||||
@@ -32,6 +39,13 @@ class SonosQueueLoader @Inject constructor(
|
|||||||
private val activeUpnpHolder: ActiveUpnpHolder,
|
private val activeUpnpHolder: ActiveUpnpHolder,
|
||||||
private val remoteState: RemotePlayerState,
|
private val remoteState: RemotePlayerState,
|
||||||
) {
|
) {
|
||||||
|
// The queue as last sent to the renderer, for looking up the track after
|
||||||
|
// the one it is playing.
|
||||||
|
@Volatile private var sent: List<TrackRef> = emptyList()
|
||||||
|
|
||||||
|
// The renderer track the next one was last prerendered for.
|
||||||
|
@Volatile private var prerenderedAfter = 0
|
||||||
|
|
||||||
suspend fun load(
|
suspend fun load(
|
||||||
transport: AVTransportClient,
|
transport: AVTransportClient,
|
||||||
route: OutputRoute,
|
route: OutputRoute,
|
||||||
@@ -46,8 +60,10 @@ class SonosQueueLoader @Inject constructor(
|
|||||||
"UPnP select: add %d initial tracks (currentIndex=%d, totalQueue=%d)",
|
"UPnP select: add %d initial tracks (currentIndex=%d, totalQueue=%d)",
|
||||||
initialBatch.size, currentIndex, queue.size,
|
initialBatch.size, currentIndex, queue.size,
|
||||||
)
|
)
|
||||||
initialBatch.forEachIndexed { idx, ref ->
|
sent = queue
|
||||||
val token = streamTokens.mint(ref.id)
|
prerenderedAfter = currentIndex + 1
|
||||||
|
initialBatch.indices.forEach { idx ->
|
||||||
|
val token = mint(queue, idx, prerender = idx == currentIndex)
|
||||||
transport.addURIToQueue(
|
transport.addURIToQueue(
|
||||||
uri = token.url,
|
uri = token.url,
|
||||||
mime = token.mime,
|
mime = token.mime,
|
||||||
@@ -64,12 +80,12 @@ class SonosQueueLoader @Inject constructor(
|
|||||||
Timber.w("UPnP select: Play")
|
Timber.w("UPnP select: Play")
|
||||||
transport.play()
|
transport.play()
|
||||||
Timber.w("UPnP select: initial done; backgrounding remainder")
|
Timber.w("UPnP select: initial done; backgrounding remainder")
|
||||||
val remaining = queue.drop(initialEnd)
|
|
||||||
// Verify even when there is no tail to append: the initial batch is
|
// Verify even when there is no tail to append: the initial batch is
|
||||||
// sent the same way and can be dropped the same way.
|
// sent the same way and can be dropped the same way.
|
||||||
scope.launch {
|
scope.launch {
|
||||||
if (remaining.isNotEmpty()) {
|
prerender(queue, currentIndex + 1)
|
||||||
extendQueueOnSonos(transport, route, remaining, initialEnd)
|
if (initialEnd < queue.size) {
|
||||||
|
extendQueueOnSonos(transport, route, queue, initialEnd)
|
||||||
}
|
}
|
||||||
verifyQueueLength(transport, route, queue)
|
verifyQueueLength(transport, route, queue)
|
||||||
}
|
}
|
||||||
@@ -84,15 +100,16 @@ class SonosQueueLoader @Inject constructor(
|
|||||||
private suspend fun extendQueueOnSonos(
|
private suspend fun extendQueueOnSonos(
|
||||||
transport: AVTransportClient,
|
transport: AVTransportClient,
|
||||||
route: OutputRoute,
|
route: OutputRoute,
|
||||||
tracks: List<TrackRef>,
|
queue: List<TrackRef>,
|
||||||
startPosition: Int,
|
startPosition: Int,
|
||||||
) {
|
) {
|
||||||
|
val count = queue.size - startPosition
|
||||||
Timber.w(
|
Timber.w(
|
||||||
"UPnP extend: appending %d tracks starting at position %d",
|
"UPnP extend: appending %d tracks starting at position %d",
|
||||||
tracks.size, startPosition + 1,
|
count, startPosition + 1,
|
||||||
)
|
)
|
||||||
val succeeded = appendTracksToQueue(transport, route, tracks, startPosition)
|
val succeeded = appendTracksToQueue(transport, route, queue, startPosition)
|
||||||
Timber.w("UPnP extend: done (%d / %d appended)", succeeded, tracks.size)
|
Timber.w("UPnP extend: done (%d / %d appended)", succeeded, count)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -136,18 +153,17 @@ class SonosQueueLoader @Inject constructor(
|
|||||||
Timber.w("UPnP verify: renderer holds %d tracks, queue intact", nrTracks)
|
Timber.w("UPnP verify: renderer holds %d tracks, queue intact", nrTracks)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val missing = fullQueue.drop(nrTracks)
|
|
||||||
Timber.w(
|
Timber.w(
|
||||||
"UPnP verify: %s holds %d of %d tracks; appending %d missing (round %d)",
|
"UPnP verify: %s holds %d of %d tracks; appending %d missing (round %d)",
|
||||||
route.name, nrTracks, fullQueue.size, missing.size, round + 1,
|
route.name, nrTracks, fullQueue.size, fullQueue.size - nrTracks, round + 1,
|
||||||
)
|
)
|
||||||
appendTracksToQueue(transport, route, missing, nrTracks)
|
appendTracksToQueue(transport, route, fullQueue, nrTracks)
|
||||||
}
|
}
|
||||||
Timber.w("UPnP verify: gave up repairing queue length on %s", route.name)
|
Timber.w("UPnP verify: gave up repairing queue length on %s", route.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Append [tracks] at [startPosition] (0-based), returning how many landed.
|
* Append [queue] from [startPosition] (0-based) on, returning how many landed.
|
||||||
* Tolerates individual AddURIToQueue failures — log and continue so some
|
* Tolerates individual AddURIToQueue failures — log and continue so some
|
||||||
* tracks loaded is better than zero tracks loaded — and stops early after
|
* tracks loaded is better than zero tracks loaded — and stops early after
|
||||||
* [EXTEND_ABORT_AFTER_FAILURES] consecutive ones.
|
* [EXTEND_ABORT_AFTER_FAILURES] consecutive ones.
|
||||||
@@ -155,20 +171,20 @@ class SonosQueueLoader @Inject constructor(
|
|||||||
private suspend fun appendTracksToQueue(
|
private suspend fun appendTracksToQueue(
|
||||||
transport: AVTransportClient,
|
transport: AVTransportClient,
|
||||||
route: OutputRoute,
|
route: OutputRoute,
|
||||||
tracks: List<TrackRef>,
|
queue: List<TrackRef>,
|
||||||
startPosition: Int,
|
startPosition: Int,
|
||||||
): Int {
|
): Int {
|
||||||
var consecutiveFailures = 0
|
var consecutiveFailures = 0
|
||||||
var succeeded = 0
|
var succeeded = 0
|
||||||
var aborted = false
|
var aborted = false
|
||||||
for ((i, ref) in tracks.withIndex()) {
|
for (i in 0 until queue.size - startPosition) {
|
||||||
if (aborted) break
|
if (aborted) break
|
||||||
if (activeUpnpHolder.active.value?.routeId != route.id) {
|
if (activeUpnpHolder.active.value?.routeId != route.id) {
|
||||||
Timber.w("UPnP extend: cancelled at offset %d (route changed)", i)
|
Timber.w("UPnP extend: cancelled at offset %d (route changed)", i)
|
||||||
aborted = true
|
aborted = true
|
||||||
} else {
|
} else {
|
||||||
val outcome = runCatching {
|
val outcome = runCatching {
|
||||||
val token = streamTokens.mint(ref.id)
|
val token = mint(queue, startPosition + i)
|
||||||
transport.addURIToQueue(
|
transport.addURIToQueue(
|
||||||
uri = token.url,
|
uri = token.url,
|
||||||
mime = token.mime,
|
mime = token.mime,
|
||||||
@@ -220,6 +236,7 @@ class SonosQueueLoader @Inject constructor(
|
|||||||
newQueue: List<TrackRef>,
|
newQueue: List<TrackRef>,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
val newIds = newQueue.map { it.id }
|
val newIds = newQueue.map { it.id }
|
||||||
|
sent = newQueue
|
||||||
if (oldIds == newIds) return true
|
if (oldIds == newIds) return true
|
||||||
val prefixLen = commonPrefixLength(oldIds, newIds)
|
val prefixLen = commonPrefixLength(oldIds, newIds)
|
||||||
val suffixLen = commonSuffixLength(
|
val suffixLen = commonSuffixLength(
|
||||||
@@ -271,8 +288,7 @@ class SonosQueueLoader @Inject constructor(
|
|||||||
prefixLen + 1,
|
prefixLen + 1,
|
||||||
)
|
)
|
||||||
for (i in 0 until addedCount) {
|
for (i in 0 until addedCount) {
|
||||||
val ref = newQueue[prefixLen + i]
|
val token = mint(newQueue, prefixLen + i)
|
||||||
val token = streamTokens.mint(ref.id)
|
|
||||||
transport.addURIToQueue(
|
transport.addURIToQueue(
|
||||||
uri = token.url,
|
uri = token.url,
|
||||||
mime = token.mime,
|
mime = token.mime,
|
||||||
@@ -283,6 +299,27 @@ class SonosQueueLoader @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called on every poll with the renderer's 1-based track number. When it
|
||||||
|
* moves, the track after it is rendered ahead, so the speaker's fetch of
|
||||||
|
* it finds the render ready.
|
||||||
|
*/
|
||||||
|
fun onRendererTrack(trackNumber: Int) {
|
||||||
|
if (trackNumber <= 0 || trackNumber == prerenderedAfter) return
|
||||||
|
prerenderedAfter = trackNumber
|
||||||
|
val queue = sent
|
||||||
|
scope.launch { prerender(queue, trackNumber) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun prerender(queue: List<TrackRef>, index: Int) {
|
||||||
|
if (index !in queue.indices) return
|
||||||
|
runCatching { mint(queue, index, prerender = true) }
|
||||||
|
.onFailure { Timber.w(it, "UPnP prerender failed for %s", queue[index].id) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun mint(queue: List<TrackRef>, index: Int, prerender: Boolean = false) =
|
||||||
|
streamTokens.mint(queue[index].id, asAlbum = playingAsAlbum(queue, index), prerender = prerender)
|
||||||
|
|
||||||
private fun commonPrefixLength(a: List<String>, b: List<String>): Int {
|
private fun commonPrefixLength(a: List<String>, b: List<String>): Int {
|
||||||
val limit = minOf(a.size, b.size)
|
val limit = minOf(a.size, b.size)
|
||||||
for (i in 0 until limit) {
|
for (i in 0 until limit) {
|
||||||
@@ -299,18 +336,33 @@ class SonosQueueLoader @Inject constructor(
|
|||||||
return limit
|
return limit
|
||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
companion object {
|
||||||
|
/**
|
||||||
|
* Whether [queue]'s track at [index] plays among its album in order,
|
||||||
|
* judged by its neighbours in the renderer's queue, which plays
|
||||||
|
* straight through.
|
||||||
|
*/
|
||||||
|
internal fun playingAsAlbum(queue: List<TrackRef>, index: Int): Boolean =
|
||||||
|
GainMath.playingAsAlbum(
|
||||||
|
prev = queue.getOrNull(index - 1)?.let(::position),
|
||||||
|
cur = position(queue[index]),
|
||||||
|
next = queue.getOrNull(index + 1)?.let(::position),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun position(t: TrackRef) =
|
||||||
|
AlbumPosition(t.albumId.ifEmpty { null }, t.discNumber, t.trackNumber)
|
||||||
|
|
||||||
// Abort the append loop after this many consecutive AddURIToQueue
|
// Abort the append loop after this many consecutive AddURIToQueue
|
||||||
// failures; Sonos rate-limits burst adds and a wall of failures means
|
// failures; Sonos rate-limits burst adds and a wall of failures means
|
||||||
// it has stopped accepting, not that the next one might land.
|
// it has stopped accepting, not that the next one might land.
|
||||||
const val EXTEND_ABORT_AFTER_FAILURES = 3
|
private const val EXTEND_ABORT_AFTER_FAILURES = 3
|
||||||
const val EXTEND_THROTTLE_MS = 50L
|
private const val EXTEND_THROTTLE_MS = 50L
|
||||||
|
|
||||||
// Verify/repair passes after a queue load. Two: one to catch the
|
// Verify/repair passes after a queue load. Two: one to catch the
|
||||||
// common case (a rate-limit burst dropped a chunk), one to catch a
|
// common case (a rate-limit burst dropped a chunk), one to catch a
|
||||||
// repair that itself got rate-limited. Beyond that the renderer is
|
// repair that itself got rate-limited. Beyond that the renderer is
|
||||||
// refusing for a reason retrying won't fix, and the stall watchdog
|
// refusing for a reason retrying won't fix, and the stall watchdog
|
||||||
// becomes the backstop.
|
// becomes the backstop.
|
||||||
const val VERIFY_ROUNDS = 2
|
private const val VERIFY_ROUNDS = 2
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+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
|
// check must decide on. Held in state rather than read inline so a test
|
||||||
// can drive the comparison without a BuildConfig.
|
// can drive the comparison without a BuildConfig.
|
||||||
val installedCode: Long = BuildConfig.VERSION_CODE.toLong(),
|
val installedCode: Long = BuildConfig.VERSION_CODE.toLong(),
|
||||||
|
// A debug build is signed with this machine's debug key, so the server's
|
||||||
|
// release-signed APK can never install over it (family idea #5103,
|
||||||
|
// practice 9). It updates from Android Studio instead.
|
||||||
|
val selfUpdates: Boolean = !BuildConfig.DEBUG,
|
||||||
val isChecking: Boolean = false,
|
val isChecking: Boolean = false,
|
||||||
val installStage: InstallStage = InstallStage.IDLE,
|
val installStage: InstallStage = InstallStage.IDLE,
|
||||||
val installMessage: String? = null,
|
val installMessage: String? = null,
|
||||||
@@ -67,7 +71,7 @@ class AboutCardViewModel @Inject constructor(
|
|||||||
val state: StateFlow<AboutUiState> = internal.asStateFlow()
|
val state: StateFlow<AboutUiState> = internal.asStateFlow()
|
||||||
|
|
||||||
fun checkForUpdates() {
|
fun checkForUpdates() {
|
||||||
if (internal.value.isChecking) return
|
if (internal.value.isChecking || !internal.value.selfUpdates) return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
internal.update { it.copy(isChecking = true, installMessage = null) }
|
internal.update { it.copy(isChecking = true, installMessage = null) }
|
||||||
val installed = internal.value.installedVersion
|
val installed = internal.value.installedVersion
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package com.fabledsword.minstrel.settings.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material3.ElevatedCard
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.SegmentedButton
|
||||||
|
import androidx.compose.material3.SegmentedButtonDefaults
|
||||||
|
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.fabledsword.minstrel.settings.data.NormalizationBoost
|
||||||
|
import com.fabledsword.minstrel.settings.data.NormalizationMode
|
||||||
|
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Volume leveling (M464 #4998). The choice is stored on the server, so the
|
||||||
|
* web player and casts follow it too.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun NormalizationCard(viewModel: NormalizationViewModel = hiltViewModel()) {
|
||||||
|
val prefs by viewModel.prefs.collectAsStateWithLifecycle()
|
||||||
|
NormalizationCardContent(prefs = prefs, onChange = viewModel::update)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
internal fun NormalizationCardContent(
|
||||||
|
prefs: NormalizationPrefs,
|
||||||
|
onChange: (NormalizationPrefs) -> Unit,
|
||||||
|
) {
|
||||||
|
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Volume leveling",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
Hint(modeHint(prefs.mode))
|
||||||
|
val modes = NormalizationMode.entries
|
||||||
|
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
modes.forEachIndexed { index, mode ->
|
||||||
|
SegmentedButton(
|
||||||
|
selected = mode == prefs.mode,
|
||||||
|
onClick = { onChange(prefs.copy(mode = mode)) },
|
||||||
|
shape = SegmentedButtonDefaults.itemShape(
|
||||||
|
index = index,
|
||||||
|
count = modes.size,
|
||||||
|
),
|
||||||
|
) { Text(modeLabel(mode)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (prefs.mode != NormalizationMode.OFF) {
|
||||||
|
TargetRow(prefs = prefs, onChange = onChange)
|
||||||
|
BoostRow(prefs = prefs, onChange = onChange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun TargetRow(prefs: NormalizationPrefs, onChange: (NormalizationPrefs) -> Unit) {
|
||||||
|
Text(
|
||||||
|
text = "Target loudness",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
val targets = NormalizationPrefs.TARGETS
|
||||||
|
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
targets.forEachIndexed { index, lufs ->
|
||||||
|
SegmentedButton(
|
||||||
|
selected = lufs == prefs.targetLufs,
|
||||||
|
onClick = { onChange(prefs.copy(targetLufs = lufs)) },
|
||||||
|
shape = SegmentedButtonDefaults.itemShape(
|
||||||
|
index = index,
|
||||||
|
count = targets.size,
|
||||||
|
),
|
||||||
|
) { Text("$lufs LUFS") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun BoostRow(prefs: NormalizationPrefs, onChange: (NormalizationPrefs) -> Unit) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = "Boost quiet tracks fully",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
Hint("A limiter catches the peaks.")
|
||||||
|
}
|
||||||
|
Spacer(Modifier.size(12.dp))
|
||||||
|
Switch(
|
||||||
|
checked = prefs.boost == NormalizationBoost.LIMITER,
|
||||||
|
onCheckedChange = { on ->
|
||||||
|
val boost = if (on) NormalizationBoost.LIMITER else NormalizationBoost.HEADROOM
|
||||||
|
onChange(prefs.copy(boost = boost))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Hint(text: String) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun modeLabel(mode: NormalizationMode): String = when (mode) {
|
||||||
|
NormalizationMode.OFF -> "Off"
|
||||||
|
NormalizationMode.AUTO -> "Auto"
|
||||||
|
NormalizationMode.TRACK -> "Track"
|
||||||
|
NormalizationMode.ALBUM -> "Album"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun modeHint(mode: NormalizationMode): String = when (mode) {
|
||||||
|
NormalizationMode.OFF -> "Tracks play at their mastered volume."
|
||||||
|
NormalizationMode.AUTO -> "Album gain for whole albums, track gain otherwise."
|
||||||
|
NormalizationMode.TRACK -> "Every track at the same loudness."
|
||||||
|
NormalizationMode.ALBUM -> "Albums keep their own quiet and loud tracks."
|
||||||
|
}
|
||||||
+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,
|
onToggle = onToggleDiagnostics,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
NormalizationCard()
|
||||||
AppearanceCard(themeMode = themeMode, onPick = onPickTheme)
|
AppearanceCard(themeMode = themeMode, onPick = onPickTheme)
|
||||||
StorageCard()
|
StorageCard()
|
||||||
AboutCard()
|
AboutCard()
|
||||||
@@ -382,6 +383,14 @@ private fun AboutCard(viewModel: AboutCardViewModel = hiltViewModel()) {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun UpdateControls(state: AboutUiState, viewModel: AboutCardViewModel) {
|
private fun UpdateControls(state: AboutUiState, viewModel: AboutCardViewModel) {
|
||||||
|
if (!state.selfUpdates) {
|
||||||
|
Text(
|
||||||
|
text = "Debug build: updates install from Android Studio.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
UpdateCheckLine(result = state.result)
|
UpdateCheckLine(result = state.result)
|
||||||
Button(
|
Button(
|
||||||
onClick = viewModel::checkForUpdates,
|
onClick = viewModel::checkForUpdates,
|
||||||
|
|||||||
+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
|
* restart re-shows it, which is acceptable nudging for v1 (matches
|
||||||
* Flutter). Server 404 / network errors stay silent. Constructed at
|
* Flutter). Server 404 / network errors stay silent. Constructed at
|
||||||
* launch via the construct-the-singleton trick in `MinstrelApplication`.
|
* launch via the construct-the-singleton trick in `MinstrelApplication`.
|
||||||
|
*
|
||||||
|
* A debug build never polls: it is signed with a local debug key, so the
|
||||||
|
* server's release-signed APK could never install over it (#5103).
|
||||||
*/
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class UpdateBannerController @Inject constructor(
|
class UpdateBannerController @Inject constructor(
|
||||||
@@ -45,6 +48,10 @@ class UpdateBannerController @Inject constructor(
|
|||||||
}.stateIn(scope, SharingStarted.Eagerly, null)
|
}.stateIn(scope, SharingStarted.Eagerly, null)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
if (!BuildConfig.DEBUG) startPolling()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startPolling() {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
while (true) {
|
while (true) {
|
||||||
runOnce()
|
runOnce()
|
||||||
|
|||||||
@@ -17,8 +17,12 @@
|
|||||||
hostnames rather than CIDR ranges, and both sets of hosts above are unknowable
|
hostnames rather than CIDR ranges, and both sets of hosts above are unknowable
|
||||||
until runtime. So a permissive base-config is an honest description of our
|
until runtime. So a permissive base-config is an honest description of our
|
||||||
situation — the gain over the manifest attribute is that the reasoning now
|
situation — the gain over the manifest attribute is that the reasoning now
|
||||||
lives somewhere, and there is one place to tighten if a future settings screen
|
lives somewhere.
|
||||||
can distinguish a LAN server from a WAN one.
|
|
||||||
|
The LAN/WAN line this file cannot draw is drawn in code instead:
|
||||||
|
api/CleartextGuard.kt refuses plain http:// to the Minstrel server whenever
|
||||||
|
the connection lands on a public address (family baseline #5105, practice
|
||||||
|
13). LAN servers and UPnP speakers are unaffected.
|
||||||
|
|
||||||
Worth stating because it looks worse than it is: this is NOT a tamper risk for
|
Worth stating because it looks worse than it is: this is NOT a tamper risk for
|
||||||
the in-app updater. An APK altered in transit and re-signed is rejected by the
|
the in-app updater. An APK altered in transit and re-signed is rejected by the
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package com.fabledsword.minstrel.api
|
||||||
|
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.mockwebserver.MockResponse
|
||||||
|
import okhttp3.mockwebserver.MockWebServer
|
||||||
|
import org.junit.jupiter.api.AfterEach
|
||||||
|
import org.junit.jupiter.api.BeforeEach
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.junit.jupiter.api.assertThrows
|
||||||
|
import java.net.InetAddress
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/** Plain http:// to the Minstrel server only on a private address (#5105, practice 13). */
|
||||||
|
class CleartextGuardTest {
|
||||||
|
private lateinit var server: MockWebServer
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
fun setup() {
|
||||||
|
server = MockWebServer().apply { start() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
fun teardown() {
|
||||||
|
server.shutdown()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ip(s: String) = InetAddress.getByName(s)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `home network and overlay addresses may use plain http`() {
|
||||||
|
for (a in listOf(
|
||||||
|
"127.0.0.1", "10.1.2.3", "172.16.0.9", "172.31.255.1", "192.168.1.20",
|
||||||
|
"169.254.3.4", "100.64.0.1", "100.127.255.254", "::1", "fd12:3456::1", "fe80::1",
|
||||||
|
)) {
|
||||||
|
assertTrue(CleartextPolicy.allows(ip(a)), "$a should be allowed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `public addresses may not`() {
|
||||||
|
for (a in listOf(
|
||||||
|
"8.8.8.8", "172.32.0.1", "100.128.0.1", "100.63.255.255", "203.0.113.5",
|
||||||
|
"2001:db8::1", "2606:4700::1111",
|
||||||
|
)) {
|
||||||
|
assertFalse(CleartextPolicy.allows(ip(a)), "$a should be refused")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun client(allows: Boolean) = OkHttpClient.Builder()
|
||||||
|
.addNetworkInterceptor(CleartextGuardInterceptor { allows })
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private fun serverRequest() = Request.Builder()
|
||||||
|
.url(server.url("/api/auth/login"))
|
||||||
|
.tag(MinstrelServerRequest::class.java, MinstrelServerRequest)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a refused server request sends nothing`() {
|
||||||
|
server.enqueue(MockResponse().setResponseCode(200))
|
||||||
|
assertThrows<CleartextToPublicHostException> {
|
||||||
|
client(allows = false).newCall(serverRequest()).execute()
|
||||||
|
}
|
||||||
|
assertEquals(0, server.requestCount, "the request reached the server")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an allowed server request goes through`() {
|
||||||
|
server.enqueue(MockResponse().setResponseCode(200))
|
||||||
|
client(allows = true).newCall(serverRequest()).execute().use { assertEquals(200, it.code) }
|
||||||
|
assertEquals(1, server.requestCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `requests not bound for the Minstrel server are left alone`() {
|
||||||
|
server.enqueue(MockResponse().setResponseCode(200))
|
||||||
|
client(allows = false).newCall(Request.Builder().url(server.url("/art.jpg")).build())
|
||||||
|
.execute().use { assertEquals(200, it.code) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the refusal has its own message`() {
|
||||||
|
val msg = ErrorCopy.fromThrowable(CleartextToPublicHostException("music.example.com"))
|
||||||
|
assertTrue(msg.contains("https://"), msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+24
@@ -1,6 +1,8 @@
|
|||||||
package com.fabledsword.minstrel.cache.mutations
|
package com.fabledsword.minstrel.cache.mutations
|
||||||
|
|
||||||
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
|
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
|
||||||
|
import com.fabledsword.minstrel.settings.data.NormalizationMode
|
||||||
|
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
@@ -129,4 +131,26 @@ class SupersededToggleIdsTest {
|
|||||||
fun `an empty queue collapses nothing`() {
|
fun `an empty queue collapses nothing`() {
|
||||||
assertTrue(supersededToggleIds(emptyList(), json).isEmpty())
|
assertTrue(supersededToggleIds(emptyList(), json).isEmpty())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun normalizationRow(id: Long, mode: NormalizationMode) = CachedMutationEntity(
|
||||||
|
id = id,
|
||||||
|
kind = MutationKind.NORMALIZATION_SET,
|
||||||
|
payload = json.encodeToString(
|
||||||
|
NormalizationPrefs.serializer(),
|
||||||
|
NormalizationPrefs.DEFAULT.copy(mode = mode),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// There is one preference per user, so any two queued changes to it
|
||||||
|
// collapse, and only the newest is sent (#4998).
|
||||||
|
@Test
|
||||||
|
fun `only the newest queued normalization change survives`() {
|
||||||
|
val rows = listOf(
|
||||||
|
normalizationRow(1, NormalizationMode.TRACK),
|
||||||
|
snoozeRow(2, "mb-a", desiredSnoozed = true),
|
||||||
|
normalizationRow(3, NormalizationMode.OFF),
|
||||||
|
normalizationRow(4, NormalizationMode.ALBUM),
|
||||||
|
)
|
||||||
|
assertEquals(setOf(1L, 3L), supersededToggleIds(rows, json))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package com.fabledsword.minstrel.player.gain
|
||||||
|
|
||||||
|
import com.fabledsword.minstrel.settings.data.NormalizationBoost
|
||||||
|
import com.fabledsword.minstrel.settings.data.NormalizationMode
|
||||||
|
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import kotlin.math.log10
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/** Mirrors web/src/lib/player/gain.test.ts: both players must level alike. */
|
||||||
|
class GainMathTest {
|
||||||
|
|
||||||
|
private fun prefs(
|
||||||
|
mode: NormalizationMode = NormalizationMode.AUTO,
|
||||||
|
target: Int = -18,
|
||||||
|
boost: NormalizationBoost = NormalizationBoost.HEADROOM,
|
||||||
|
) = NormalizationPrefs(mode = mode, targetLufs = target, boost = boost)
|
||||||
|
|
||||||
|
private val g = ReplayGain(trackGain = -6f, trackPeak = 1f, albumGain = -4f, albumPeak = 1f)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `off and unmeasured tracks play as mastered`() {
|
||||||
|
assertEquals(0f, GainMath.gainDb(prefs(mode = NormalizationMode.OFF), g, false))
|
||||||
|
assertEquals(0f, GainMath.gainDb(prefs(), null, false))
|
||||||
|
assertEquals(0f, GainMath.gainDb(prefs(), ReplayGain.NONE, false))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `mode picks the gain and auto follows album play`() {
|
||||||
|
assertEquals(-6f, GainMath.gainDb(prefs(mode = NormalizationMode.TRACK), g, true))
|
||||||
|
assertEquals(-4f, GainMath.gainDb(prefs(mode = NormalizationMode.ALBUM), g, false))
|
||||||
|
assertEquals(-4f, GainMath.gainDb(prefs(), g, true))
|
||||||
|
assertEquals(-6f, GainMath.gainDb(prefs(), g, false))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `album gain falls back to track gain until the album is measured`() {
|
||||||
|
val partial = ReplayGain(trackGain = -6f, trackPeak = 1f, albumGain = null, albumPeak = null)
|
||||||
|
assertEquals(-6f, GainMath.gainDb(prefs(mode = NormalizationMode.ALBUM), partial, true))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a louder target raises every gain by the difference`() {
|
||||||
|
assertEquals(-2f, GainMath.gainDb(prefs(mode = NormalizationMode.TRACK, target = -14), g, false))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `headroom stops a boost 1 dB under the true peak and the limiter lets it through`() {
|
||||||
|
val quiet = ReplayGain(trackGain = 8f, trackPeak = 0.5f, albumGain = null, albumPeak = null)
|
||||||
|
val headroom = GainMath.gainDb(prefs(mode = NormalizationMode.TRACK), quiet, false)
|
||||||
|
assertEquals(-1f - 20f * log10(0.5f), headroom, 1e-4f)
|
||||||
|
val limited = GainMath.gainDb(
|
||||||
|
prefs(mode = NormalizationMode.TRACK, boost = NormalizationBoost.LIMITER),
|
||||||
|
quiet,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
assertEquals(8f, limited)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `no boost passes the cap`() {
|
||||||
|
val silent = ReplayGain(trackGain = 30f, trackPeak = 0.001f, albumGain = null, albumPeak = null)
|
||||||
|
val db = GainMath.gainDb(
|
||||||
|
prefs(mode = NormalizationMode.TRACK, boost = NormalizationBoost.LIMITER),
|
||||||
|
silent,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
assertEquals(GainMath.MAX_BOOST_DB, db)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `cuts are never limited by the peak`() {
|
||||||
|
val hot = ReplayGain(trackGain = -9f, trackPeak = 1.4f, albumGain = null, albumPeak = null)
|
||||||
|
assertEquals(-9f, GainMath.gainDb(prefs(mode = NormalizationMode.TRACK), hot, false))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pos(album: String, track: Int?, disc: Int? = 1) = AlbumPosition(album, disc, track)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `in-order neighbours from the same album are album play`() {
|
||||||
|
assertTrue(GainMath.playingAsAlbum(null, pos("x", 1), pos("x", 2)))
|
||||||
|
assertTrue(GainMath.playingAsAlbum(pos("x", 1), pos("x", 2), pos("x", 3)))
|
||||||
|
assertTrue(GainMath.playingAsAlbum(pos("x", 2), pos("x", 3), null))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a shuffled album, a mix and a lone track are not album play`() {
|
||||||
|
assertFalse(GainMath.playingAsAlbum(null, pos("x", 3), pos("x", 1)))
|
||||||
|
assertFalse(GainMath.playingAsAlbum(null, pos("x", 1), pos("y", 2)))
|
||||||
|
assertFalse(GainMath.playingAsAlbum(null, pos("x", 1), null))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `disc order counts`() {
|
||||||
|
assertTrue(GainMath.playingAsAlbum(pos("x", 12, disc = 1), pos("x", 1, disc = 2), null))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a neighbour without a track number is not evidence of order`() {
|
||||||
|
assertFalse(GainMath.playingAsAlbum(pos("x", null), pos("x", 3), null))
|
||||||
|
assertFalse(GainMath.playingAsAlbum(null, pos("x", null), pos("x", null)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `dbToLinear`() {
|
||||||
|
assertEquals(1f, GainMath.dbToLinear(0f))
|
||||||
|
assertEquals(0.1f, GainMath.dbToLinear(-20f), 1e-6f)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package com.fabledsword.minstrel.player.gain
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import java.nio.ByteOrder
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class GainStageTest {
|
||||||
|
|
||||||
|
private fun floats(vararg v: Float): ByteBuffer =
|
||||||
|
ByteBuffer.allocateDirect(v.size * 4).order(ByteOrder.nativeOrder()).apply {
|
||||||
|
v.forEach { putFloat(it) }
|
||||||
|
flip()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun shorts(vararg v: Short): ByteBuffer =
|
||||||
|
ByteBuffer.allocateDirect(v.size * 2).order(ByteOrder.nativeOrder()).apply {
|
||||||
|
v.forEach { putShort(it) }
|
||||||
|
flip()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun runFloat(stage: GainStage, input: ByteBuffer, target: Float, limiting: Boolean): FloatArray {
|
||||||
|
val out = ByteBuffer.allocateDirect(input.remaining()).order(ByteOrder.nativeOrder())
|
||||||
|
stage.process(input, out, channels = 2, isFloat = true, target = target, limiting = limiting)
|
||||||
|
out.flip()
|
||||||
|
return FloatArray(out.remaining() / 4) { out.float }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a stream that starts at its gain is scaled from the first sample`() {
|
||||||
|
val stage = GainStage().apply { reset(sampleRate = 44_100, channels = 2, startGain = 0.5f) }
|
||||||
|
val out = runFloat(stage, floats(0.8f, -0.4f), target = 0.5f, limiting = false)
|
||||||
|
assertEquals(0.4f, out[0], 1e-6f)
|
||||||
|
assertEquals(-0.2f, out[1], 1e-6f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a changed target ramps rather than steps`() {
|
||||||
|
val stage = GainStage().apply { reset(sampleRate = 44_100, channels = 2, startGain = 1f) }
|
||||||
|
val n = 44_100 // one second of stereo frames
|
||||||
|
val input = floats(*FloatArray(n * 2) { 0.5f })
|
||||||
|
val out = runFloat(stage, input, target = 0.5f, limiting = false)
|
||||||
|
assertTrue(out[0] > 0.49f, "first frame barely moved: ${out[0]}")
|
||||||
|
assertEquals(0.25f, out.last(), 1e-3f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the limiter holds a boosted peak at -1 dBFS`() {
|
||||||
|
val stage = GainStage().apply { reset(sampleRate = 44_100, channels = 2, startGain = 4f) }
|
||||||
|
val out = runFloat(stage, floats(0.9f, -0.9f, 0.1f, 0.1f), target = 4f, limiting = true)
|
||||||
|
out.forEach { assertTrue(abs(it) <= GainStage.LIMIT_CEILING + 1e-6f, "sample $it over the ceiling") }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `without the limiter a boost is clamped to full scale, 16-bit included`() {
|
||||||
|
val stage = GainStage().apply { reset(sampleRate = 44_100, channels = 2, startGain = 4f) }
|
||||||
|
val input = shorts(20_000, -20_000)
|
||||||
|
val out = ByteBuffer.allocateDirect(4).order(ByteOrder.nativeOrder())
|
||||||
|
stage.process(input, out, channels = 2, isFloat = false, target = 4f, limiting = false)
|
||||||
|
out.flip()
|
||||||
|
assertEquals(Short.MAX_VALUE, out.short)
|
||||||
|
assertEquals((-Short.MAX_VALUE).toShort(), out.short)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `unity is a pass-through`() {
|
||||||
|
val stage = GainStage().apply { reset(sampleRate = 44_100, channels = 2, startGain = 1f) }
|
||||||
|
assertTrue(stage.isUnity(target = 1f, limiting = false))
|
||||||
|
assertTrue(!stage.isUnity(target = 1f, limiting = true))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package com.fabledsword.minstrel.player.gain
|
||||||
|
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.ReplayGainApi
|
||||||
|
import com.fabledsword.minstrel.cache.db.dao.CachedReplayGain
|
||||||
|
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
|
||||||
|
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||||
|
import com.fabledsword.minstrel.models.wire.ReplayGainResponseWire
|
||||||
|
import com.fabledsword.minstrel.models.wire.ReplayGainWire
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.coVerify
|
||||||
|
import io.mockk.mockk
|
||||||
|
import kotlinx.coroutines.test.TestScope
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import java.io.IOException
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
|
||||||
|
/** The fallback ladder: the library cache, then the server, then nothing. */
|
||||||
|
class ReplayGainStoreTest {
|
||||||
|
private val dao: CachedTrackDao = mockk()
|
||||||
|
private val api: ReplayGainApi = mockk()
|
||||||
|
private var health = ServerHealth.Healthy
|
||||||
|
|
||||||
|
private fun store() = ReplayGainStore(
|
||||||
|
scope = TestScope(),
|
||||||
|
trackDao = dao,
|
||||||
|
api = api,
|
||||||
|
serverHealth = { health },
|
||||||
|
clock = { 0L },
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `cached gains are used and only the rest are asked of the server`() = runTest {
|
||||||
|
coEvery { dao.replayGains(listOf("a", "b", "c")) } returns listOf(
|
||||||
|
CachedReplayGain("a", trackGain = -6f, trackPeak = 1f, albumGain = -4f, albumPeak = 1f),
|
||||||
|
// Cached but unmeasured at the last sync: the server may know more.
|
||||||
|
CachedReplayGain("b", trackGain = null, trackPeak = null, albumGain = null, albumPeak = null),
|
||||||
|
)
|
||||||
|
coEvery { api.getReplayGain("b,c") } returns ReplayGainResponseWire(
|
||||||
|
items = mapOf("b" to ReplayGainWire(trackGain = -3f, trackPeak = 0.9f)),
|
||||||
|
)
|
||||||
|
val s = store()
|
||||||
|
|
||||||
|
s.load(listOf("a", "b", "c"))
|
||||||
|
|
||||||
|
assertEquals(-4f, s.get("a")?.albumGain)
|
||||||
|
assertEquals(-3f, s.get("b")?.trackGain)
|
||||||
|
assertNull(s.get("c"), "a track the server has not measured stays unknown")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `offline, the cache is all there is`() = runTest {
|
||||||
|
health = ServerHealth.Offline
|
||||||
|
coEvery { dao.replayGains(any()) } returns emptyList()
|
||||||
|
val s = store()
|
||||||
|
|
||||||
|
s.load(listOf("a"))
|
||||||
|
|
||||||
|
assertNull(s.get("a"))
|
||||||
|
coVerify(exactly = 0) { api.getReplayGain(any()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a failed lookup leaves the track unknown, to be asked again`() = runTest {
|
||||||
|
coEvery { dao.replayGains(any()) } returns emptyList()
|
||||||
|
coEvery { api.getReplayGain(any()) } throws IOException("reset")
|
||||||
|
val s = store()
|
||||||
|
|
||||||
|
s.load(listOf("a"))
|
||||||
|
|
||||||
|
assertNull(s.get("a"))
|
||||||
|
}
|
||||||
|
}
|
||||||
+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-coroutines = "1.9.0"
|
||||||
kotlinx-datetime = "0.6.1"
|
kotlinx-datetime = "0.6.1"
|
||||||
kotlinx-serialization-converter = "1.0.0"
|
kotlinx-serialization-converter = "1.0.0"
|
||||||
media3 = "1.10.1"
|
media3 = "1.11.0"
|
||||||
mediarouter = "1.7.0"
|
mediarouter = "1.7.0"
|
||||||
coil = "3.0.0-rc02"
|
coil = "3.0.0-rc02"
|
||||||
palette = "1.0.0"
|
palette = "1.0.0"
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ require (
|
|||||||
github.com/jackc/pgx/v5 v5.9.2
|
github.com/jackc/pgx/v5 v5.9.2
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
golang.org/x/crypto v0.51.0
|
golang.org/x/crypto v0.51.0
|
||||||
|
golang.org/x/sync v0.21.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -25,7 +26,6 @@ require (
|
|||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||||
golang.org/x/sync v0.21.0 // indirect
|
|
||||||
golang.org/x/sys v0.44.0 // indirect
|
golang.org/x/sys v0.44.0 // indirect
|
||||||
golang.org/x/text v0.39.0 // indirect
|
golang.org/x/text v0.39.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -46,10 +46,15 @@ func (h *handlers) handleGetLoudnessCoverage(w http.ResponseWriter, r *http.Requ
|
|||||||
type loudnessSettingsBody struct {
|
type loudnessSettingsBody struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
BackfillConcurrency int32 `json:"backfill_concurrency"`
|
BackfillConcurrency int32 `json:"backfill_concurrency"`
|
||||||
|
LeveledCacheMB int32 `json:"leveled_cache_mb"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func loudnessSettingsBodyOf(s library.LoudnessSettings) loudnessSettingsBody {
|
func loudnessSettingsBodyOf(s library.LoudnessSettings) loudnessSettingsBody {
|
||||||
return loudnessSettingsBody{Enabled: s.Enabled, BackfillConcurrency: s.BackfillConcurrency}
|
return loudnessSettingsBody{
|
||||||
|
Enabled: s.Enabled,
|
||||||
|
BackfillConcurrency: s.BackfillConcurrency,
|
||||||
|
LeveledCacheMB: s.LeveledCacheMB,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleGetLoudnessSettings implements GET /api/admin/library/loudness-settings.
|
// handleGetLoudnessSettings implements GET /api/admin/library/loudness-settings.
|
||||||
@@ -69,6 +74,7 @@ func (h *handlers) handleUpdateLoudnessSettings(w http.ResponseWriter, r *http.R
|
|||||||
saved, err := h.loudnessSettings.Set(r.Context(), library.LoudnessSettings{
|
saved, err := h.loudnessSettings.Set(r.Context(), library.LoudnessSettings{
|
||||||
Enabled: req.Enabled,
|
Enabled: req.Enabled,
|
||||||
BackfillConcurrency: req.BackfillConcurrency,
|
BackfillConcurrency: req.BackfillConcurrency,
|
||||||
|
LeveledCacheMB: req.LeveledCacheMB,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, library.ErrLoudnessSettingOutOfRange) {
|
if errors.Is(err, library.ErrLoudnessSettingOutOfRange) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ package api
|
|||||||
import (
|
import (
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
@@ -66,6 +67,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
|||||||
reacqSettings: reacqSettings,
|
reacqSettings: reacqSettings,
|
||||||
fingerprintSettings: fpSettings,
|
fingerprintSettings: fpSettings,
|
||||||
loudnessSettings: loudSettings,
|
loudnessSettings: loudSettings,
|
||||||
|
leveled: newLeveledRenderer(dataDir, loudSettings, logger),
|
||||||
librarySize: recommendation.NewLibrarySize(nil),
|
librarySize: recommendation.NewLibrarySize(nil),
|
||||||
loginGuard: auth.NewLoginGuard(),
|
loginGuard: auth.NewLoginGuard(),
|
||||||
setupToken: setupToken,
|
setupToken: setupToken,
|
||||||
@@ -96,6 +98,9 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
|||||||
// audio format from the path. The {ext} param is consumed by chi
|
// audio format from the path. The {ext} param is consumed by chi
|
||||||
// and ignored by the handler (which keys off {id}). See task #610.
|
// and ignored by the handler (which keys off {id}). See task #610.
|
||||||
api.With(auth.OptionalUser(pool, logger)).Get("/tracks/{id}/stream.{ext}", h.handleGetStream)
|
api.With(auth.OptionalUser(pool, logger)).Get("/tracks/{id}/stream.{ext}", h.handleGetStream)
|
||||||
|
// The leveled stream for Sonos/UPnP (M464 #5001): session or a
|
||||||
|
// leveled token, like the plain stream.
|
||||||
|
api.With(auth.OptionalUser(pool, logger)).Get("/tracks/{id}/leveled.flac", h.handleGetLeveledStream)
|
||||||
|
|
||||||
api.Group(func(authed chi.Router) {
|
api.Group(func(authed chi.Router) {
|
||||||
authed.Use(auth.RequireUser(pool, netSettings.Hops))
|
authed.Use(auth.RequireUser(pool, netSettings.Hops))
|
||||||
@@ -109,6 +114,8 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
|||||||
authed.Put("/me/password", h.handleChangePassword)
|
authed.Put("/me/password", h.handleChangePassword)
|
||||||
authed.Put("/me/profile", h.handleUpdateMyProfile)
|
authed.Put("/me/profile", h.handleUpdateMyProfile)
|
||||||
authed.Put("/me/timezone", h.handlePutTimezone)
|
authed.Put("/me/timezone", h.handlePutTimezone)
|
||||||
|
authed.Get("/me/normalization", h.handleGetMyNormalization)
|
||||||
|
authed.Put("/me/normalization", h.handlePutMyNormalization)
|
||||||
authed.Post("/me/api-token", h.handleRegenerateMyAPIToken)
|
authed.Post("/me/api-token", h.handleRegenerateMyAPIToken)
|
||||||
authed.Get("/me/subsonic-password", h.handleGetMySubsonicPassword)
|
authed.Get("/me/subsonic-password", h.handleGetMySubsonicPassword)
|
||||||
authed.Post("/me/subsonic-password", h.handleGenerateMySubsonicPassword)
|
authed.Post("/me/subsonic-password", h.handleGenerateMySubsonicPassword)
|
||||||
@@ -341,6 +348,10 @@ type handlers struct {
|
|||||||
// loudnessSettings is the loudness analysis policy (M464 #4995), the same
|
// loudnessSettings is the loudness analysis policy (M464 #4995), the same
|
||||||
// instance the loudness backfill reads. Nil serves the defaults.
|
// instance the loudness backfill reads. Nil serves the defaults.
|
||||||
loudnessSettings *library.LoudnessSettingsService
|
loudnessSettings *library.LoudnessSettingsService
|
||||||
|
// leveled renders the leveled streams handed to Sonos/UPnP speakers
|
||||||
|
// (M464 #5001). Nil when its cache directory cannot be made: a level
|
||||||
|
// request then gets the plain stream.
|
||||||
|
leveled *library.LeveledRenderer
|
||||||
// setupToken must accompany the first registration while no users exist
|
// setupToken must accompany the first registration while no users exist
|
||||||
// (see auth.SetupToken). requireSetupToken is set by Mount, the only
|
// (see auth.SetupToken). requireSetupToken is set by Mount, the only
|
||||||
// production constructor; tests that build handlers directly leave it
|
// production constructor; tests that build handlers directly leave it
|
||||||
@@ -368,3 +379,14 @@ type handlers struct {
|
|||||||
// anything a client mints), which is the desired slice-1 default.
|
// anything a client mints), which is the desired slice-1 default.
|
||||||
streamSecret []byte
|
streamSecret []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// newLeveledRenderer makes the leveled-stream renderer, caching under the
|
||||||
|
// data directory. A failure is logged and leaves leveling off for speakers.
|
||||||
|
func newLeveledRenderer(dataDir string, settings *library.LoudnessSettingsService, logger *slog.Logger) *library.LeveledRenderer {
|
||||||
|
r, err := library.NewLeveledRenderer(filepath.Join(dataDir, "leveled-cache"), settings, logger)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("api: leveled streams unavailable", "err", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -19,6 +20,17 @@ const (
|
|||||||
type castTokenRequest struct {
|
type castTokenRequest struct {
|
||||||
TrackID string `json:"trackId"`
|
TrackID string `json:"trackId"`
|
||||||
ExpSeconds int `json:"expSeconds,omitempty"`
|
ExpSeconds int `json:"expSeconds,omitempty"`
|
||||||
|
// Level asks for the leveled stream (M464 #5001): the track rendered at
|
||||||
|
// the user's loudness gain. AsAlbum says the track is playing as part of
|
||||||
|
// its album in order, which picks album gain in auto mode; only the
|
||||||
|
// client holding the queue knows it.
|
||||||
|
Level bool `json:"level,omitempty"`
|
||||||
|
AsAlbum bool `json:"asAlbum,omitempty"`
|
||||||
|
// Prerender says the speaker will fetch this track soon: the current
|
||||||
|
// track at a queue load, or the next one as it starts. Only those are
|
||||||
|
// rendered ahead; a queue load mints every track and renders none of
|
||||||
|
// the rest until the speaker asks.
|
||||||
|
Prerender bool `json:"prerender,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type castTokenResponse struct {
|
type castTokenResponse struct {
|
||||||
@@ -31,6 +43,10 @@ type castTokenResponse struct {
|
|||||||
// `<res protocolInfo>` and `<dc:title>` without a follow-up round trip.
|
// `<res protocolInfo>` and `<dc:title>` without a follow-up round trip.
|
||||||
MIME string `json:"mime"`
|
MIME string `json:"mime"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
|
// Leveled is true when URL is the leveled stream. A level request still
|
||||||
|
// gets the plain stream when there is nothing to change: leveling off,
|
||||||
|
// the track not yet measured, or a gain of 0.
|
||||||
|
Leveled bool `json:"leveled"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// mimeForFormat returns the audio MIME type for a cast (Sonos/UPnP) URL.
|
// mimeForFormat returns the audio MIME type for a cast (Sonos/UPnP) URL.
|
||||||
@@ -89,7 +105,8 @@ func extForFormat(format string) string {
|
|||||||
// Part of the output-picker UPnP slice. See
|
// Part of the output-picker UPnP slice. See
|
||||||
// docs/superpowers/specs/2026-06-03-android-output-picker-upnp-design.md.
|
// docs/superpowers/specs/2026-06-03-android-output-picker-upnp-design.md.
|
||||||
func (h *handlers) handleCastStreamToken(w http.ResponseWriter, r *http.Request) {
|
func (h *handlers) handleCastStreamToken(w http.ResponseWriter, r *http.Request) {
|
||||||
if _, ok := requireUser(w, r); !ok {
|
user, ok := requireUser(w, r)
|
||||||
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var req castTokenRequest
|
var req castTokenRequest
|
||||||
@@ -112,6 +129,27 @@ func (h *handlers) handleCastStreamToken(w http.ResponseWriter, r *http.Request)
|
|||||||
expSec := clampExpSeconds(req.ExpSeconds)
|
expSec := clampExpSeconds(req.ExpSeconds)
|
||||||
exp := time.Now().Add(time.Duration(expSec) * time.Second).Unix()
|
exp := time.Now().Add(time.Duration(expSec) * time.Second).Unix()
|
||||||
token := SignStreamToken(h.streamSecret, req.TrackID, exp)
|
token := SignStreamToken(h.streamSecret, req.TrackID, exp)
|
||||||
|
path := streamURLWithExt(trackUUID, extForFormat(track.FileFormat)) +
|
||||||
|
"?token=" + token + "&exp=" + strconv.FormatInt(exp, 10)
|
||||||
|
mime := mimeForFormat(track.FileFormat)
|
||||||
|
leveled := false
|
||||||
|
if req.Level && h.leveled != nil {
|
||||||
|
g, err := h.leveledGainFor(r.Context(), user.ID, trackUUID, req.AsAlbum)
|
||||||
|
if err != nil {
|
||||||
|
// The plain stream still plays; only the leveling is lost.
|
||||||
|
h.logger.Warn("cast token: leveled gain lookup failed", "track", req.TrackID, "err", err)
|
||||||
|
} else if !g.Unity() {
|
||||||
|
token = SignLeveledStreamToken(h.streamSecret, req.TrackID, exp, g)
|
||||||
|
path = leveledStreamPath(trackUUID) + leveledQuery(g, token, exp)
|
||||||
|
mime = "audio/flac"
|
||||||
|
leveled = true
|
||||||
|
if req.Prerender {
|
||||||
|
h.leveled.Prerender(library.LeveledSource{
|
||||||
|
TrackID: req.TrackID, Path: track.FilePath, DurationMs: track.DurationMs,
|
||||||
|
}, g)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Behind a TLS-terminating reverse proxy, r.TLS is nil even though
|
// Behind a TLS-terminating reverse proxy, r.TLS is nil even though
|
||||||
// the public-facing URL is https://. UPnP devices (Sonos especially)
|
// the public-facing URL is https://. UPnP devices (Sonos especially)
|
||||||
@@ -131,18 +169,16 @@ func (h *handlers) handleCastStreamToken(w http.ResponseWriter, r *http.Request)
|
|||||||
if h := r.Header.Get("X-Forwarded-Host"); h != "" {
|
if h := r.Header.Get("X-Forwarded-Host"); h != "" {
|
||||||
host = h
|
host = h
|
||||||
}
|
}
|
||||||
// Include the file extension in the path so Sonos's URL probe sees a
|
// The path carries a file extension so Sonos's URL probe sees a
|
||||||
// recognizable audio file. Without it, Sonos reports TrackDuration=0
|
// recognizable audio file. Without it, Sonos reports TrackDuration=0
|
||||||
// and seeks past 0s land "after the end" -> early track-skip.
|
// and seeks past 0s land "after the end" -> early track-skip.
|
||||||
url := scheme + "://" + host + streamURLWithExt(trackUUID, extForFormat(track.FileFormat)) +
|
|
||||||
"?token=" + token + "&exp=" + strconv.FormatInt(exp, 10)
|
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, castTokenResponse{
|
writeJSON(w, http.StatusOK, castTokenResponse{
|
||||||
Token: token,
|
Token: token,
|
||||||
Exp: exp,
|
Exp: exp,
|
||||||
URL: url,
|
URL: scheme + "://" + host + path,
|
||||||
MIME: mimeForFormat(track.FileFormat),
|
MIME: mime,
|
||||||
Title: track.Title,
|
Title: track.Title,
|
||||||
|
Leveled: leveled,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
SELECT file_path
|
||||||
FROM tracks
|
FROM tracks
|
||||||
WHERE album_id = a.id
|
WHERE album_id = a.id
|
||||||
|
AND missing_since IS NULL
|
||||||
ORDER BY disc_number NULLS LAST, track_number NULLS LAST, id
|
ORDER BY disc_number NULLS LAST, track_number NULLS LAST, id
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
) t ON true
|
) t ON true
|
||||||
@@ -569,7 +570,9 @@ type ListAlbumsMissingMbidWithTrackRow struct {
|
|||||||
|
|
||||||
// One-shot MBID backfill: returns each album where mbid IS NULL alongside
|
// One-shot MBID backfill: returns each album where mbid IS NULL alongside
|
||||||
// one of its tracks' file_path so the worker can re-read tags. LIMIT
|
// one of its tracks' file_path so the worker can re-read tags. LIMIT
|
||||||
// supplied by caller for batching/progress purposes.
|
// supplied by caller for batching/progress purposes. The track read is
|
||||||
|
// one still on disk; an album with none left is skipped until a scan
|
||||||
|
// finds its files again (#5139).
|
||||||
func (q *Queries) ListAlbumsMissingMbidWithTrack(ctx context.Context, limit int32) ([]ListAlbumsMissingMbidWithTrackRow, error) {
|
func (q *Queries) ListAlbumsMissingMbidWithTrack(ctx context.Context, limit int32) ([]ListAlbumsMissingMbidWithTrackRow, error) {
|
||||||
rows, err := q.db.Query(ctx, listAlbumsMissingMbidWithTrack, limit)
|
rows, err := q.db.Query(ctx, listAlbumsMissingMbidWithTrack, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ func (q *Queries) GetLoudnessCoverage(ctx context.Context, currentVersion int16)
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getLoudnessSettings = `-- name: GetLoudnessSettings :one
|
const getLoudnessSettings = `-- name: GetLoudnessSettings :one
|
||||||
SELECT id, enabled, backfill_concurrency, updated_at FROM loudness_settings WHERE id = true
|
SELECT id, enabled, backfill_concurrency, updated_at, leveled_cache_mb FROM loudness_settings WHERE id = true
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetLoudnessSettings(ctx context.Context) (LoudnessSetting, error) {
|
func (q *Queries) GetLoudnessSettings(ctx context.Context) (LoudnessSetting, error) {
|
||||||
@@ -138,6 +138,7 @@ func (q *Queries) GetLoudnessSettings(ctx context.Context) (LoudnessSetting, err
|
|||||||
&i.Enabled,
|
&i.Enabled,
|
||||||
&i.BackfillConcurrency,
|
&i.BackfillConcurrency,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.LeveledCacheMb,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -388,26 +389,29 @@ const updateLoudnessSettings = `-- name: UpdateLoudnessSettings :one
|
|||||||
UPDATE loudness_settings
|
UPDATE loudness_settings
|
||||||
SET enabled = $1,
|
SET enabled = $1,
|
||||||
backfill_concurrency = $2,
|
backfill_concurrency = $2,
|
||||||
|
leveled_cache_mb = $3,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE id = true
|
WHERE id = true
|
||||||
RETURNING id, enabled, backfill_concurrency, updated_at
|
RETURNING id, enabled, backfill_concurrency, updated_at, leveled_cache_mb
|
||||||
`
|
`
|
||||||
|
|
||||||
type UpdateLoudnessSettingsParams struct {
|
type UpdateLoudnessSettingsParams struct {
|
||||||
Enabled bool
|
Enabled bool
|
||||||
BackfillConcurrency int32
|
BackfillConcurrency int32
|
||||||
|
LeveledCacheMb int32
|
||||||
}
|
}
|
||||||
|
|
||||||
// Whole-row write from the admin card; migration 0065's CHECK is the backstop
|
// Whole-row write from the admin card; the migrations' CHECKs are the backstop
|
||||||
// behind the service's own validation.
|
// behind the service's own validation.
|
||||||
func (q *Queries) UpdateLoudnessSettings(ctx context.Context, arg UpdateLoudnessSettingsParams) (LoudnessSetting, error) {
|
func (q *Queries) UpdateLoudnessSettings(ctx context.Context, arg UpdateLoudnessSettingsParams) (LoudnessSetting, error) {
|
||||||
row := q.db.QueryRow(ctx, updateLoudnessSettings, arg.Enabled, arg.BackfillConcurrency)
|
row := q.db.QueryRow(ctx, updateLoudnessSettings, arg.Enabled, arg.BackfillConcurrency, arg.LeveledCacheMb)
|
||||||
var i LoudnessSetting
|
var i LoudnessSetting
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
&i.ID,
|
&i.ID,
|
||||||
&i.Enabled,
|
&i.Enabled,
|
||||||
&i.BackfillConcurrency,
|
&i.BackfillConcurrency,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.LeveledCacheMb,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -432,6 +432,7 @@ type LoudnessSetting struct {
|
|||||||
Enabled bool
|
Enabled bool
|
||||||
BackfillConcurrency int32
|
BackfillConcurrency int32
|
||||||
UpdatedAt pgtype.Timestamptz
|
UpdatedAt pgtype.Timestamptz
|
||||||
|
LeveledCacheMb int32
|
||||||
}
|
}
|
||||||
|
|
||||||
type MissingReacquisition struct {
|
type MissingReacquisition struct {
|
||||||
@@ -784,6 +785,14 @@ type UserInvite struct {
|
|||||||
RedeemedBy pgtype.UUID
|
RedeemedBy pgtype.UUID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UserNormalizationPref struct {
|
||||||
|
UserID pgtype.UUID
|
||||||
|
Mode string
|
||||||
|
TargetLufs int16
|
||||||
|
Boost string
|
||||||
|
UpdatedAt pgtype.Timestamptz
|
||||||
|
}
|
||||||
|
|
||||||
type YouMightLikeAlbum struct {
|
type YouMightLikeAlbum struct {
|
||||||
UserID pgtype.UUID
|
UserID pgtype.UUID
|
||||||
AlbumID pgtype.UUID
|
AlbumID pgtype.UUID
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.31.1
|
||||||
|
// source: normalization_prefs.sql
|
||||||
|
|
||||||
|
package dbq
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
const getNormalizationPrefs = `-- name: GetNormalizationPrefs :one
|
||||||
|
SELECT mode, target_lufs, boost, updated_at
|
||||||
|
FROM user_normalization_prefs
|
||||||
|
WHERE user_id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetNormalizationPrefsRow struct {
|
||||||
|
Mode string
|
||||||
|
TargetLufs int16
|
||||||
|
Boost string
|
||||||
|
UpdatedAt pgtype.Timestamptz
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetNormalizationPrefs(ctx context.Context, userID pgtype.UUID) (GetNormalizationPrefsRow, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getNormalizationPrefs, userID)
|
||||||
|
var i GetNormalizationPrefsRow
|
||||||
|
err := row.Scan(
|
||||||
|
&i.Mode,
|
||||||
|
&i.TargetLufs,
|
||||||
|
&i.Boost,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const upsertNormalizationPrefs = `-- name: UpsertNormalizationPrefs :one
|
||||||
|
INSERT INTO user_normalization_prefs (user_id, mode, target_lufs, boost)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
ON CONFLICT (user_id) DO UPDATE SET
|
||||||
|
mode = EXCLUDED.mode,
|
||||||
|
target_lufs = EXCLUDED.target_lufs,
|
||||||
|
boost = EXCLUDED.boost,
|
||||||
|
updated_at = now()
|
||||||
|
RETURNING mode, target_lufs, boost, updated_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpsertNormalizationPrefsParams struct {
|
||||||
|
UserID pgtype.UUID
|
||||||
|
Mode string
|
||||||
|
TargetLufs int16
|
||||||
|
Boost string
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpsertNormalizationPrefsRow struct {
|
||||||
|
Mode string
|
||||||
|
TargetLufs int16
|
||||||
|
Boost string
|
||||||
|
UpdatedAt pgtype.Timestamptz
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whole-row write from either client; the CHECKs in migration 0067 back the
|
||||||
|
// handler's own validation.
|
||||||
|
func (q *Queries) UpsertNormalizationPrefs(ctx context.Context, arg UpsertNormalizationPrefsParams) (UpsertNormalizationPrefsRow, error) {
|
||||||
|
row := q.db.QueryRow(ctx, upsertNormalizationPrefs,
|
||||||
|
arg.UserID,
|
||||||
|
arg.Mode,
|
||||||
|
arg.TargetLufs,
|
||||||
|
arg.Boost,
|
||||||
|
)
|
||||||
|
var i UpsertNormalizationPrefsRow
|
||||||
|
err := row.Scan(
|
||||||
|
&i.Mode,
|
||||||
|
&i.TargetLufs,
|
||||||
|
&i.Boost,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
@@ -668,6 +668,7 @@ const listTracksMissingMbidWithPath = `-- name: ListTracksMissingMbidWithPath :m
|
|||||||
SELECT id, file_path
|
SELECT id, file_path
|
||||||
FROM tracks
|
FROM tracks
|
||||||
WHERE mbid IS NULL
|
WHERE mbid IS NULL
|
||||||
|
AND missing_since IS NULL
|
||||||
ORDER BY id
|
ORDER BY id
|
||||||
LIMIT $1
|
LIMIT $1
|
||||||
`
|
`
|
||||||
@@ -679,6 +680,8 @@ type ListTracksMissingMbidWithPathRow struct {
|
|||||||
|
|
||||||
// Track recording-MBID backfill: tracks with NULL mbid that still have
|
// Track recording-MBID backfill: tracks with NULL mbid that still have
|
||||||
// a file to re-read. $1 caps the batch (mirrors the album backfill).
|
// a file to re-read. $1 caps the batch (mirrors the album backfill).
|
||||||
|
// Missing tracks are skipped: there is no file, and opening it anyway
|
||||||
|
// logged a warning per track on every scan (#5139).
|
||||||
func (q *Queries) ListTracksMissingMbidWithPath(ctx context.Context, limit int32) ([]ListTracksMissingMbidWithPathRow, error) {
|
func (q *Queries) ListTracksMissingMbidWithPath(ctx context.Context, limit int32) ([]ListTracksMissingMbidWithPathRow, error) {
|
||||||
rows, err := q.db.Query(ctx, listTracksMissingMbidWithPath, limit)
|
rows, err := q.db.Query(ctx, listTracksMissingMbidWithPath, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS user_normalization_prefs;
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
-- Each user's loudness-normalization preference (M464 #4998). It is kept on
|
||||||
|
-- the server, not per device, so the web player, the Android app and the
|
||||||
|
-- leveled stream for UPnP/Sonos all apply the same one.
|
||||||
|
--
|
||||||
|
-- mode: off, auto (album gain while an album plays in order, otherwise
|
||||||
|
-- track gain), track or album.
|
||||||
|
-- target: the loudness tracks are leveled to, in LUFS.
|
||||||
|
-- boost: headroom raises quiet tracks only as far as their true peak
|
||||||
|
-- allows; limiter raises them all the way and limits the peaks.
|
||||||
|
--
|
||||||
|
-- No row means the defaults, which are the column defaults here and
|
||||||
|
-- library.DefaultNormalizationPrefs in Go (a test holds them equal).
|
||||||
|
CREATE TABLE user_normalization_prefs (
|
||||||
|
user_id uuid PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
mode text NOT NULL DEFAULT 'auto'
|
||||||
|
CHECK (mode IN ('off', 'auto', 'track', 'album')),
|
||||||
|
target_lufs smallint NOT NULL DEFAULT -18
|
||||||
|
CHECK (target_lufs IN (-18, -16, -14)),
|
||||||
|
boost text NOT NULL DEFAULT 'headroom'
|
||||||
|
CHECK (boost IN ('headroom', 'limiter')),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE loudness_settings
|
||||||
|
DROP CONSTRAINT loudness_settings_leveled_cache_range,
|
||||||
|
DROP COLUMN leveled_cache_mb;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- 0068_leveled_stream_cache.up.sql — the size cap of the leveled-stream render
|
||||||
|
-- cache (Scribe milestone #464, #5001).
|
||||||
|
--
|
||||||
|
-- Sonos and other UPnP renderers fetch a track's URL themselves, so the phone
|
||||||
|
-- cannot level what they play. The server renders a gain-applied FLAC for them
|
||||||
|
-- instead and keeps it on disk, oldest-used first out once the cache is over
|
||||||
|
-- this size. An operator setting (rule 25), on the loudness settings row it
|
||||||
|
-- belongs with.
|
||||||
|
ALTER TABLE loudness_settings
|
||||||
|
ADD COLUMN leveled_cache_mb integer NOT NULL DEFAULT 2048,
|
||||||
|
ADD CONSTRAINT loudness_settings_leveled_cache_range
|
||||||
|
CHECK (leveled_cache_mb >= 256 AND leveled_cache_mb <= 65536);
|
||||||
@@ -145,7 +145,9 @@ UPDATE albums
|
|||||||
-- name: ListAlbumsMissingMbidWithTrack :many
|
-- name: ListAlbumsMissingMbidWithTrack :many
|
||||||
-- One-shot MBID backfill: returns each album where mbid IS NULL alongside
|
-- One-shot MBID backfill: returns each album where mbid IS NULL alongside
|
||||||
-- one of its tracks' file_path so the worker can re-read tags. LIMIT
|
-- one of its tracks' file_path so the worker can re-read tags. LIMIT
|
||||||
-- supplied by caller for batching/progress purposes.
|
-- supplied by caller for batching/progress purposes. The track read is
|
||||||
|
-- one still on disk; an album with none left is skipped until a scan
|
||||||
|
-- finds its files again (#5139).
|
||||||
SELECT a.id AS album_id,
|
SELECT a.id AS album_id,
|
||||||
a.artist_id AS artist_id,
|
a.artist_id AS artist_id,
|
||||||
a.title AS title,
|
a.title AS title,
|
||||||
@@ -155,6 +157,7 @@ SELECT a.id AS album_id,
|
|||||||
SELECT file_path
|
SELECT file_path
|
||||||
FROM tracks
|
FROM tracks
|
||||||
WHERE album_id = a.id
|
WHERE album_id = a.id
|
||||||
|
AND missing_since IS NULL
|
||||||
ORDER BY disc_number NULLS LAST, track_number NULLS LAST, id
|
ORDER BY disc_number NULLS LAST, track_number NULLS LAST, id
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
) t ON true
|
) t ON true
|
||||||
|
|||||||
@@ -67,11 +67,12 @@ SELECT count(*)::bigint AS total,
|
|||||||
SELECT * FROM loudness_settings WHERE id = true;
|
SELECT * FROM loudness_settings WHERE id = true;
|
||||||
|
|
||||||
-- name: UpdateLoudnessSettings :one
|
-- name: UpdateLoudnessSettings :one
|
||||||
-- Whole-row write from the admin card; migration 0065's CHECK is the backstop
|
-- Whole-row write from the admin card; the migrations' CHECKs are the backstop
|
||||||
-- behind the service's own validation.
|
-- behind the service's own validation.
|
||||||
UPDATE loudness_settings
|
UPDATE loudness_settings
|
||||||
SET enabled = sqlc.arg(enabled),
|
SET enabled = sqlc.arg(enabled),
|
||||||
backfill_concurrency = sqlc.arg(backfill_concurrency),
|
backfill_concurrency = sqlc.arg(backfill_concurrency),
|
||||||
|
leveled_cache_mb = sqlc.arg(leveled_cache_mb),
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE id = true
|
WHERE id = true
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- name: GetNormalizationPrefs :one
|
||||||
|
SELECT mode, target_lufs, boost, updated_at
|
||||||
|
FROM user_normalization_prefs
|
||||||
|
WHERE user_id = $1;
|
||||||
|
|
||||||
|
-- name: UpsertNormalizationPrefs :one
|
||||||
|
-- Whole-row write from either client; the CHECKs in migration 0067 back the
|
||||||
|
-- handler's own validation.
|
||||||
|
INSERT INTO user_normalization_prefs (user_id, mode, target_lufs, boost)
|
||||||
|
VALUES (sqlc.arg(user_id), sqlc.arg(mode), sqlc.arg(target_lufs), sqlc.arg(boost))
|
||||||
|
ON CONFLICT (user_id) DO UPDATE SET
|
||||||
|
mode = EXCLUDED.mode,
|
||||||
|
target_lufs = EXCLUDED.target_lufs,
|
||||||
|
boost = EXCLUDED.boost,
|
||||||
|
updated_at = now()
|
||||||
|
RETURNING mode, target_lufs, boost, updated_at;
|
||||||
@@ -26,9 +26,12 @@ RETURNING *;
|
|||||||
-- name: ListTracksMissingMbidWithPath :many
|
-- name: ListTracksMissingMbidWithPath :many
|
||||||
-- Track recording-MBID backfill: tracks with NULL mbid that still have
|
-- Track recording-MBID backfill: tracks with NULL mbid that still have
|
||||||
-- a file to re-read. $1 caps the batch (mirrors the album backfill).
|
-- a file to re-read. $1 caps the batch (mirrors the album backfill).
|
||||||
|
-- Missing tracks are skipped: there is no file, and opening it anyway
|
||||||
|
-- logged a warning per track on every scan (#5139).
|
||||||
SELECT id, file_path
|
SELECT id, file_path
|
||||||
FROM tracks
|
FROM tracks
|
||||||
WHERE mbid IS NULL
|
WHERE mbid IS NULL
|
||||||
|
AND missing_since IS NULL
|
||||||
ORDER BY id
|
ORDER BY id
|
||||||
LIMIT $1;
|
LIMIT $1;
|
||||||
|
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ var dataTables = []string{
|
|||||||
"track_fingerprints", // M400
|
"track_fingerprints", // M400
|
||||||
"track_loudness", // M464
|
"track_loudness", // M464
|
||||||
"album_loudness", // M464
|
"album_loudness", // M464
|
||||||
|
"user_normalization_prefs", // M464
|
||||||
"tracks",
|
"tracks",
|
||||||
"albums",
|
"albums",
|
||||||
"artists",
|
"artists",
|
||||||
|
|||||||
@@ -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.
|
// values, so normalization keeps working for them.
|
||||||
Enabled bool
|
Enabled bool
|
||||||
BackfillConcurrency int32
|
BackfillConcurrency int32
|
||||||
|
// LeveledCacheMB caps the disk the leveled-stream renders for Sonos and
|
||||||
|
// UPnP speakers may use (#5001); the least recently played go first.
|
||||||
|
LeveledCacheMB int32
|
||||||
// UpdatedAt is set by the database; ignored by Set.
|
// UpdatedAt is set by the database; ignored by Set.
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultLoudnessSettings mirrors migration 0065's column defaults, so a
|
// DefaultLoudnessSettings mirrors migrations 0065 and 0068's column defaults, so a
|
||||||
// database that cannot be read still analyzes the way a fresh install does.
|
// database that cannot be read still analyzes the way a fresh install does.
|
||||||
var DefaultLoudnessSettings = LoudnessSettings{
|
var DefaultLoudnessSettings = LoudnessSettings{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
BackfillConcurrency: loudnessBackfillConcurrency,
|
BackfillConcurrency: loudnessBackfillConcurrency,
|
||||||
|
LeveledCacheMB: 2048,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The leveled-stream cache's bounds, as migration 0068's CHECK has them.
|
||||||
|
const (
|
||||||
|
minLeveledCacheMB = 256
|
||||||
|
maxLeveledCacheMB = 65536
|
||||||
|
)
|
||||||
|
|
||||||
// ErrLoudnessSettingOutOfRange is returned by Set for a value migration 0065's
|
// ErrLoudnessSettingOutOfRange is returned by Set for a value migration 0065's
|
||||||
// CHECK would reject, so the API answers 400 naming the field.
|
// CHECK would reject, so the API answers 400 naming the field.
|
||||||
var ErrLoudnessSettingOutOfRange = errors.New("loudness setting out of range")
|
var ErrLoudnessSettingOutOfRange = errors.New("loudness setting out of range")
|
||||||
@@ -78,6 +88,7 @@ func (s *LoudnessSettingsService) Set(ctx context.Context, in LoudnessSettings)
|
|||||||
row, err := dbq.New(s.pool).UpdateLoudnessSettings(ctx, dbq.UpdateLoudnessSettingsParams{
|
row, err := dbq.New(s.pool).UpdateLoudnessSettings(ctx, dbq.UpdateLoudnessSettingsParams{
|
||||||
Enabled: in.Enabled,
|
Enabled: in.Enabled,
|
||||||
BackfillConcurrency: in.BackfillConcurrency,
|
BackfillConcurrency: in.BackfillConcurrency,
|
||||||
|
LeveledCacheMb: in.LeveledCacheMB,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return LoudnessSettings{}, fmt.Errorf("loudness settings: save: %w", err)
|
return LoudnessSettings{}, fmt.Errorf("loudness settings: save: %w", err)
|
||||||
@@ -94,6 +105,10 @@ func validateLoudnessSettings(in LoudnessSettings) error {
|
|||||||
return fmt.Errorf("%w: backfill_concurrency must be %d-%d",
|
return fmt.Errorf("%w: backfill_concurrency must be %d-%d",
|
||||||
ErrLoudnessSettingOutOfRange, minBackfillConcurrency, maxBackfillConcurrency)
|
ErrLoudnessSettingOutOfRange, minBackfillConcurrency, maxBackfillConcurrency)
|
||||||
}
|
}
|
||||||
|
if in.LeveledCacheMB < minLeveledCacheMB || in.LeveledCacheMB > maxLeveledCacheMB {
|
||||||
|
return fmt.Errorf("%w: leveled_cache_mb must be %d-%d",
|
||||||
|
ErrLoudnessSettingOutOfRange, minLeveledCacheMB, maxLeveledCacheMB)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,6 +116,7 @@ func loudnessSettingsFromRow(row dbq.LoudnessSetting) LoudnessSettings {
|
|||||||
return LoudnessSettings{
|
return LoudnessSettings{
|
||||||
Enabled: row.Enabled,
|
Enabled: row.Enabled,
|
||||||
BackfillConcurrency: row.BackfillConcurrency,
|
BackfillConcurrency: row.BackfillConcurrency,
|
||||||
|
LeveledCacheMB: row.LeveledCacheMb,
|
||||||
UpdatedAt: row.UpdatedAt.Time,
|
UpdatedAt: row.UpdatedAt.Time,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -233,14 +233,27 @@ func TestBackfillLoudnessResult_Add(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestValidateLoudnessSettings(t *testing.T) {
|
func TestValidateLoudnessSettings(t *testing.T) {
|
||||||
for _, n := range []int32{minBackfillConcurrency, maxBackfillConcurrency} {
|
with := func(concurrency, cacheMB int32) LoudnessSettings {
|
||||||
if err := validateLoudnessSettings(LoudnessSettings{BackfillConcurrency: n}); err != nil {
|
s := DefaultLoudnessSettings
|
||||||
t.Errorf("concurrency %d rejected: %v", n, err)
|
s.BackfillConcurrency, s.LeveledCacheMB = concurrency, cacheMB
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
for _, s := range []LoudnessSettings{
|
||||||
|
with(minBackfillConcurrency, minLeveledCacheMB),
|
||||||
|
with(maxBackfillConcurrency, maxLeveledCacheMB),
|
||||||
|
} {
|
||||||
|
if err := validateLoudnessSettings(s); err != nil {
|
||||||
|
t.Errorf("%+v rejected: %v", s, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, n := range []int32{0, maxBackfillConcurrency + 1} {
|
for _, s := range []LoudnessSettings{
|
||||||
if err := validateLoudnessSettings(LoudnessSettings{BackfillConcurrency: n}); !errors.Is(err, ErrLoudnessSettingOutOfRange) {
|
with(0, minLeveledCacheMB),
|
||||||
t.Errorf("concurrency %d: err = %v, want ErrLoudnessSettingOutOfRange", n, err)
|
with(maxBackfillConcurrency+1, minLeveledCacheMB),
|
||||||
|
with(minBackfillConcurrency, minLeveledCacheMB-1),
|
||||||
|
with(minBackfillConcurrency, maxLeveledCacheMB+1),
|
||||||
|
} {
|
||||||
|
if err := validateLoudnessSettings(s); !errors.Is(err, ErrLoudnessSettingOutOfRange) {
|
||||||
|
t.Errorf("%+v: err = %v, want ErrLoudnessSettingOutOfRange", s, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var nilSvc *LoudnessSettingsService
|
var nilSvc *LoudnessSettingsService
|
||||||
|
|||||||
@@ -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 = {
|
export type LoudnessSettings = {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
backfill_concurrency: number;
|
backfill_concurrency: number;
|
||||||
|
/** Disk for the leveled copies rendered for Sonos/UPnP speakers (#5001). */
|
||||||
|
leveled_cache_mb: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function getLoudnessSettings(): Promise<LoudnessSettings> {
|
export async function getLoudnessSettings(): Promise<LoudnessSettings> {
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { api, apiFetch } from './client';
|
||||||
|
|
||||||
|
// Loudness normalization preference (M464 #4998). One per user, stored on
|
||||||
|
// the server so the web player, the Android app and casts agree.
|
||||||
|
export type NormalizationMode = 'off' | 'auto' | 'track' | 'album';
|
||||||
|
export type NormalizationTarget = -18 | -16 | -14;
|
||||||
|
export type NormalizationBoost = 'headroom' | 'limiter';
|
||||||
|
|
||||||
|
export type NormalizationPrefs = {
|
||||||
|
mode: NormalizationMode;
|
||||||
|
target_lufs: NormalizationTarget;
|
||||||
|
boost: NormalizationBoost;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Must match library.DefaultNormalizationPrefs on the server.
|
||||||
|
export const DEFAULT_NORMALIZATION: NormalizationPrefs = {
|
||||||
|
mode: 'auto',
|
||||||
|
target_lufs: -18,
|
||||||
|
boost: 'headroom'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NORMALIZATION_MODES: NormalizationMode[] = ['off', 'auto', 'track', 'album'];
|
||||||
|
export const NORMALIZATION_TARGETS: NormalizationTarget[] = [-18, -16, -14];
|
||||||
|
|
||||||
|
export function isNormalizationPrefs(v: unknown): v is NormalizationPrefs {
|
||||||
|
if (typeof v !== 'object' || v === null) return false;
|
||||||
|
const p = v as Record<string, unknown>;
|
||||||
|
return (
|
||||||
|
NORMALIZATION_MODES.includes(p.mode as NormalizationMode) &&
|
||||||
|
NORMALIZATION_TARGETS.includes(p.target_lufs as NormalizationTarget) &&
|
||||||
|
(p.boost === 'headroom' || p.boost === 'limiter')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNormalization(): Promise<NormalizationPrefs> {
|
||||||
|
return api.get<NormalizationPrefs>('/api/me/normalization');
|
||||||
|
}
|
||||||
|
|
||||||
|
// The whole preference is sent every time; the server keeps the last write.
|
||||||
|
export function putNormalization(p: NormalizationPrefs): Promise<NormalizationPrefs> {
|
||||||
|
return api.put<NormalizationPrefs>('/api/me/normalization', p);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-track gains (#4997): dB to the -18 LUFS ReplayGain reference and linear
|
||||||
|
// peaks. A field is absent until the server has measured it.
|
||||||
|
export type ReplayGain = {
|
||||||
|
track_gain?: number;
|
||||||
|
track_peak?: number;
|
||||||
|
album_gain?: number;
|
||||||
|
album_peak?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gains for up to 200 tracks; an id missing from items has none yet. Given
|
||||||
|
* a deadline: the player waits on it, and a request that never answers
|
||||||
|
* would leave those tracks unleveled for the rest of the session.
|
||||||
|
*/
|
||||||
|
export function getReplayGain(ids: string[]): Promise<{ items: Record<string, ReplayGain> }> {
|
||||||
|
return apiFetch(`/api/tracks/replay-gain?ids=${ids.map(encodeURIComponent).join(',')}`, {
|
||||||
|
signal: AbortSignal.timeout(10_000)
|
||||||
|
}) as Promise<{ items: Record<string, ReplayGain> }>;
|
||||||
|
}
|
||||||
@@ -11,7 +11,8 @@
|
|||||||
import { pushToast } from '$lib/stores/toast.svelte';
|
import { pushToast } from '$lib/stores/toast.svelte';
|
||||||
|
|
||||||
// Loudness analysis (#4995): the background measurement that loudness
|
// Loudness analysis (#4995): the background measurement that loudness
|
||||||
// normalization levels playback from, its progress, and its two knobs.
|
// normalization levels playback from, its progress, and its knobs. The
|
||||||
|
// cache size bounds the leveled copies rendered for speakers (#5001).
|
||||||
|
|
||||||
let saved = $state<LoudnessSettings | null>(null);
|
let saved = $state<LoudnessSettings | null>(null);
|
||||||
let form = $state<LoudnessSettings | null>(null);
|
let form = $state<LoudnessSettings | null>(null);
|
||||||
@@ -26,6 +27,13 @@
|
|||||||
form.backfill_concurrency >= 1 &&
|
form.backfill_concurrency >= 1 &&
|
||||||
form.backfill_concurrency <= 8
|
form.backfill_concurrency <= 8
|
||||||
);
|
);
|
||||||
|
const cacheOk = $derived(
|
||||||
|
!!form &&
|
||||||
|
Number.isInteger(form.leveled_cache_mb) &&
|
||||||
|
form.leveled_cache_mb >= 256 &&
|
||||||
|
form.leveled_cache_mb <= 65536
|
||||||
|
);
|
||||||
|
const valid = $derived(concurrencyOk && cacheOk);
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
@@ -55,7 +63,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
if (!form || !concurrencyOk) return;
|
if (!form || !valid) return;
|
||||||
saving = true;
|
saving = true;
|
||||||
try {
|
try {
|
||||||
saved = await updateLoudnessSettings(form);
|
saved = await updateLoudnessSettings(form);
|
||||||
@@ -151,10 +159,26 @@
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{#if !concurrencyOk}
|
<label class="block">
|
||||||
<p class="text-xs text-action-destructive" data-testid="settings-problems">
|
<span class="text-sm text-text-primary">Speaker cache (MB)</span>
|
||||||
Files analyzed at once must be a whole number from 1 to 8.
|
<span class="block text-xs text-text-secondary">
|
||||||
</p>
|
Leveled copies for Sonos and UPnP speakers. The least recently played go first.
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="256"
|
||||||
|
max="65536"
|
||||||
|
step="256"
|
||||||
|
bind:value={form.leveled_cache_mb}
|
||||||
|
class={inputClass}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{#if !valid}
|
||||||
|
<div class="text-xs text-action-destructive" data-testid="settings-problems">
|
||||||
|
{#if !concurrencyOk}<p>Files analyzed at once must be a whole number from 1 to 8.</p>{/if}
|
||||||
|
{#if !cacheOk}<p>Speaker cache must be a whole number of MB from 256 to 65536.</p>{/if}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="flex justify-end">
|
<div class="flex justify-end">
|
||||||
@@ -163,7 +187,7 @@
|
|||||||
class="rounded-md bg-action-secondary px-4 py-2 text-sm text-action-fg hover:opacity-90
|
class="rounded-md bg-action-secondary px-4 py-2 text-sm text-action-fg hover:opacity-90
|
||||||
focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent
|
focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent
|
||||||
disabled:cursor-not-allowed disabled:opacity-50"
|
disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
disabled={!dirty || saving || !concurrencyOk}
|
disabled={!dirty || saving || !valid}
|
||||||
onclick={save}
|
onclick={save}
|
||||||
>
|
>
|
||||||
{saving ? 'Saving…' : 'Save'}
|
{saving ? 'Saving…' : 'Save'}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import LoudnessSettingsCard from './LoudnessSettingsCard.svelte';
|
|||||||
import { getLoudnessCoverage, getLoudnessSettings, updateLoudnessSettings } from '$lib/api/admin';
|
import { getLoudnessCoverage, getLoudnessSettings, updateLoudnessSettings } from '$lib/api/admin';
|
||||||
import { pushToast } from '$lib/stores/toast.svelte';
|
import { pushToast } from '$lib/stores/toast.svelte';
|
||||||
|
|
||||||
const base: LoudnessSettings = { enabled: true, backfill_concurrency: 2 };
|
const base: LoudnessSettings = { enabled: true, backfill_concurrency: 2, leveled_cache_mb: 2048 };
|
||||||
const coverage: LoudnessCoverage = {
|
const coverage: LoudnessCoverage = {
|
||||||
total: 1200,
|
total: 1200,
|
||||||
measured: 900,
|
measured: 900,
|
||||||
@@ -68,7 +68,11 @@ describe('LoudnessSettingsCard', () => {
|
|||||||
await fireEvent.click(saveButton());
|
await fireEvent.click(saveButton());
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(updateLoudnessSettings).toHaveBeenCalledWith({ enabled: true, backfill_concurrency: 4 })
|
expect(updateLoudnessSettings).toHaveBeenCalledWith({
|
||||||
|
enabled: true,
|
||||||
|
backfill_concurrency: 4,
|
||||||
|
leveled_cache_mb: 2048
|
||||||
|
})
|
||||||
);
|
);
|
||||||
await waitFor(() => expect(pushToast).toHaveBeenCalledWith('Loudness analysis settings saved.'));
|
await waitFor(() => expect(pushToast).toHaveBeenCalledWith('Loudness analysis settings saved.'));
|
||||||
// The gauge is read again after a save: switching analysis on or off changes it.
|
// The gauge is read again after a save: switching analysis on or off changes it.
|
||||||
@@ -84,6 +88,16 @@ describe('LoudnessSettingsCard', () => {
|
|||||||
expect(saveButton()).toHaveProperty('disabled', true);
|
expect(saveButton()).toHaveProperty('disabled', true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('the speaker cache size is bounded too', async () => {
|
||||||
|
await renderCard();
|
||||||
|
const cache = screen.getByRole('spinbutton', { name: /speaker cache/i });
|
||||||
|
await fireEvent.input(cache, { target: { value: '100' } });
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByTestId('settings-problems').textContent).toMatch(/256 to 65536/)
|
||||||
|
);
|
||||||
|
expect(saveButton()).toHaveProperty('disabled', true);
|
||||||
|
});
|
||||||
|
|
||||||
test('a failed load offers a retry', async () => {
|
test('a failed load offers a retry', async () => {
|
||||||
vi.mocked(getLoudnessSettings).mockRejectedValue(new Error('boom'));
|
vi.mocked(getLoudnessSettings).mockRejectedValue(new Error('boom'));
|
||||||
vi.mocked(getLoudnessCoverage).mockResolvedValue(coverage);
|
vi.mocked(getLoudnessCoverage).mockResolvedValue(coverage);
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import {
|
||||||
|
NORMALIZATION_MODES,
|
||||||
|
NORMALIZATION_TARGETS,
|
||||||
|
type NormalizationPrefs
|
||||||
|
} from '$lib/api/normalization';
|
||||||
|
import {
|
||||||
|
normalization,
|
||||||
|
loadNormalization,
|
||||||
|
setNormalization
|
||||||
|
} from '$lib/stores/normalization.svelte';
|
||||||
|
import { pushToast } from '$lib/stores/toast.svelte';
|
||||||
|
|
||||||
|
// Volume leveling (M464 #4998). The choice is the user's, stored on the
|
||||||
|
// server, so it follows them to the Android app and to casts.
|
||||||
|
onMount(() => {
|
||||||
|
void loadNormalization();
|
||||||
|
});
|
||||||
|
|
||||||
|
const modeLabels: Record<string, string> = {
|
||||||
|
off: 'Off',
|
||||||
|
auto: 'Auto',
|
||||||
|
track: 'Track',
|
||||||
|
album: 'Album'
|
||||||
|
};
|
||||||
|
const modeHints: Record<string, string> = {
|
||||||
|
off: 'Tracks play at their mastered volume.',
|
||||||
|
auto: 'Album gain for whole albums in order, track gain otherwise.',
|
||||||
|
track: 'Every track at the same loudness.',
|
||||||
|
album: 'Albums keep their own quiet and loud tracks.'
|
||||||
|
};
|
||||||
|
|
||||||
|
async function change(patch: Partial<NormalizationPrefs>): Promise<void> {
|
||||||
|
try {
|
||||||
|
await setNormalization({ ...normalization.value, ...patch });
|
||||||
|
} catch {
|
||||||
|
pushToast('Could not save volume leveling.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pill = (on: boolean) =>
|
||||||
|
`flex cursor-pointer items-center rounded border border-border px-3 py-1.5 text-sm ${
|
||||||
|
on ? 'bg-action-secondary text-action-fg' : 'text-text-primary hover:bg-surface-hover'
|
||||||
|
}`;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<fieldset class="space-y-2">
|
||||||
|
<legend class="block text-sm text-text-secondary">Volume leveling</legend>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{#each NORMALIZATION_MODES as m (m)}
|
||||||
|
<label class={pill(normalization.value.mode === m)}>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="normalization-mode"
|
||||||
|
value={m}
|
||||||
|
checked={normalization.value.mode === m}
|
||||||
|
onchange={() => change({ mode: m })}
|
||||||
|
class="sr-only"
|
||||||
|
/>
|
||||||
|
{modeLabels[m]}
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-text-secondary">{modeHints[normalization.value.mode]}</p>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
{#if normalization.value.mode !== 'off'}
|
||||||
|
<fieldset class="space-y-2">
|
||||||
|
<legend class="block text-sm text-text-secondary">Target loudness</legend>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{#each NORMALIZATION_TARGETS as t (t)}
|
||||||
|
<label class={pill(normalization.value.target_lufs === t)}>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="normalization-target"
|
||||||
|
value={t}
|
||||||
|
checked={normalization.value.target_lufs === t}
|
||||||
|
onchange={() => change({ target_lufs: t })}
|
||||||
|
class="sr-only"
|
||||||
|
/>
|
||||||
|
<span class="tabular-nums">{t} LUFS</span>
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-text-secondary">Higher is louder; −18 leaves the most headroom.</p>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<label class="flex cursor-pointer items-center justify-between gap-3">
|
||||||
|
<span>
|
||||||
|
<span class="block text-sm text-text-primary">Boost quiet tracks fully</span>
|
||||||
|
<span class="block text-xs text-text-secondary">A limiter catches the peaks.</span>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={normalization.value.boost === 'limiter'}
|
||||||
|
checked={normalization.value.boost === 'limiter'}
|
||||||
|
onchange={(e) =>
|
||||||
|
change({ boost: (e.currentTarget as HTMLInputElement).checked ? 'limiter' : 'headroom' })}
|
||||||
|
class="h-4 w-4 accent-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||||||
|
|
||||||
|
vi.mock('$lib/api/normalization', async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import('$lib/api/normalization')>()),
|
||||||
|
getNormalization: vi.fn(),
|
||||||
|
putNormalization: vi.fn()
|
||||||
|
}));
|
||||||
|
vi.mock('$lib/stores/toast.svelte', () => ({ pushToast: vi.fn() }));
|
||||||
|
|
||||||
|
import NormalizationSetting from './NormalizationSetting.svelte';
|
||||||
|
import { getNormalization, putNormalization } from '$lib/api/normalization';
|
||||||
|
import { normalization } from '$lib/stores/normalization.svelte';
|
||||||
|
import { pushToast } from '$lib/stores/toast.svelte';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('NormalizationSetting', () => {
|
||||||
|
test('shows the server value and saves the whole preference on a change', async () => {
|
||||||
|
vi.mocked(getNormalization).mockResolvedValue({ mode: 'track', target_lufs: -16, boost: 'headroom' });
|
||||||
|
vi.mocked(putNormalization).mockImplementation(async (p) => p);
|
||||||
|
render(NormalizationSetting);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByRole('radio', { name: 'Track' })).toBeChecked());
|
||||||
|
expect(screen.getByRole('radio', { name: '-16 LUFS' })).toBeChecked();
|
||||||
|
|
||||||
|
await fireEvent.click(screen.getByRole('radio', { name: 'Album' }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(putNormalization).toHaveBeenCalledWith({ mode: 'album', target_lufs: -16, boost: 'headroom' })
|
||||||
|
);
|
||||||
|
await fireEvent.click(screen.getByRole('switch', { name: /boost quiet tracks/i }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(putNormalization).toHaveBeenLastCalledWith({ mode: 'album', target_lufs: -16, boost: 'limiter' })
|
||||||
|
);
|
||||||
|
// The last value seen is cached for the player's first track of a session.
|
||||||
|
expect(JSON.parse(localStorage.getItem('minstrel:normalization') ?? 'null')).toEqual({
|
||||||
|
mode: 'album',
|
||||||
|
target_lufs: -16,
|
||||||
|
boost: 'limiter'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a failed save puts the previous choice back and says so', async () => {
|
||||||
|
vi.mocked(getNormalization).mockResolvedValue({ mode: 'auto', target_lufs: -18, boost: 'headroom' });
|
||||||
|
vi.mocked(putNormalization).mockRejectedValue(new Error('offline'));
|
||||||
|
render(NormalizationSetting);
|
||||||
|
await waitFor(() => expect(screen.getByRole('radio', { name: 'Auto' })).toBeChecked());
|
||||||
|
|
||||||
|
await fireEvent.click(screen.getByRole('radio', { name: 'Off' }));
|
||||||
|
await waitFor(() => expect(pushToast).toHaveBeenCalledWith('Could not save volume leveling.', 'error'));
|
||||||
|
expect(normalization.value.mode).toBe('auto');
|
||||||
|
expect(screen.getByRole('radio', { name: 'Auto' })).toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('off hides the target and boost controls', async () => {
|
||||||
|
vi.mocked(getNormalization).mockResolvedValue({ mode: 'off', target_lufs: -18, boost: 'headroom' });
|
||||||
|
render(NormalizationSetting);
|
||||||
|
await waitFor(() => expect(screen.getByRole('radio', { name: 'Off' })).toBeChecked());
|
||||||
|
expect(screen.queryByRole('radio', { name: '-18 LUFS' })).toBeNull();
|
||||||
|
expect(screen.queryByRole('switch')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 { applyMetaThemeColor } from '$lib/theme/applyMetaThemeColor.svelte';
|
||||||
import { audioLoader } from '$lib/player/audioLoader';
|
import { audioLoader } from '$lib/player/audioLoader';
|
||||||
import { attachStallRetry } from '$lib/player/stallRetry';
|
import { attachStallRetry } from '$lib/player/stallRetry';
|
||||||
|
import {
|
||||||
|
applyGain,
|
||||||
|
ensureGains,
|
||||||
|
ensureGraph,
|
||||||
|
gainDbAt,
|
||||||
|
resumeGraph
|
||||||
|
} from '$lib/player/gainStage.svelte';
|
||||||
|
import { untrack } from 'svelte';
|
||||||
|
|
||||||
let { children } = $props<{ children: import('svelte').Snippet }>();
|
let { children } = $props<{ children: import('svelte').Snippet }>();
|
||||||
let audioEl: HTMLAudioElement | undefined = $state();
|
let audioEl: HTMLAudioElement | undefined = $state();
|
||||||
@@ -95,8 +103,39 @@
|
|||||||
// Per-position fade scalar: 1 normally, ramping in/out of the
|
// Per-position fade scalar: 1 normally, ramping in/out of the
|
||||||
// crossfade window at track boundaries. Pure function of position
|
// crossfade window at track boundaries. Pure function of position
|
||||||
// + duration + the operator's chosen crossfade duration.
|
// + duration + the operator's chosen crossfade duration.
|
||||||
|
// Leveling (#4999) multiplies in last; the prefetch element is muted
|
||||||
|
// and never leveled.
|
||||||
const scalar = deriveFadeScalar(player.position, player.duration, player.crossfadeSec);
|
const scalar = deriveFadeScalar(player.position, player.duration, player.crossfadeSec);
|
||||||
audioEl.volume = player.volume * scalar;
|
applyGain(audioEl, player.volume * scalar, gainDbAt(player.index));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Gains for the current track and those after it, fetched as the queue
|
||||||
|
// moves so each track's gain is in hand before it starts.
|
||||||
|
$effect(() => {
|
||||||
|
if (user.value === null) return;
|
||||||
|
player.queue;
|
||||||
|
player.index;
|
||||||
|
untrack(() => void ensureGains());
|
||||||
|
});
|
||||||
|
|
||||||
|
// A boost needs the Web Audio graph. It is built when the current or next
|
||||||
|
// track wants one, and from a user gesture if the browser refused a
|
||||||
|
// context without one.
|
||||||
|
const wantsBoost = $derived(gainDbAt(player.index) > 0 || gainDbAt(player.index + 1) > 0);
|
||||||
|
$effect(() => {
|
||||||
|
if (!audioEl || !wantsBoost) return;
|
||||||
|
const el = audioEl;
|
||||||
|
untrack(() => void ensureGraph(el));
|
||||||
|
const onGesture = () => {
|
||||||
|
resumeGraph();
|
||||||
|
void ensureGraph(el);
|
||||||
|
};
|
||||||
|
document.addEventListener('pointerdown', onGesture);
|
||||||
|
document.addEventListener('keydown', onGesture);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('pointerdown', onGesture);
|
||||||
|
document.removeEventListener('keydown', onGesture);
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -105,6 +144,7 @@
|
|||||||
// emitted 'playing' yet, but we still need to call play() to get there.
|
// emitted 'playing' yet, but we still need to call play() to get there.
|
||||||
const intent = player.state === 'playing' || player.state === 'loading';
|
const intent = player.state === 'playing' || player.state === 'loading';
|
||||||
if (intent) {
|
if (intent) {
|
||||||
|
resumeGraph();
|
||||||
audioEl.play().catch(() => { /* interrupted / blocked — store gets the event */ });
|
audioEl.play().catch(() => { /* interrupted / blocked — store gets the event */ });
|
||||||
} else {
|
} else {
|
||||||
audioEl.pause();
|
audioEl.pause();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { LayoutLoad } from './$types';
|
import type { LayoutLoad } from './$types';
|
||||||
import { bootstrap, user } from '$lib/auth/store.svelte';
|
import { bootstrap, user } from '$lib/auth/store.svelte';
|
||||||
import { restoreQueue } from '$lib/player/store.svelte';
|
import { restoreQueue } from '$lib/player/store.svelte';
|
||||||
|
import { loadNormalization } from '$lib/stores/normalization.svelte';
|
||||||
|
|
||||||
export const ssr = false; // adapter-static fallback; we're SPA-only
|
export const ssr = false; // adapter-static fallback; we're SPA-only
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
@@ -8,6 +9,10 @@ export const prerender = false;
|
|||||||
export const load: LayoutLoad = async () => {
|
export const load: LayoutLoad = async () => {
|
||||||
await bootstrap();
|
await bootstrap();
|
||||||
const userId = user.value?.id;
|
const userId = user.value?.id;
|
||||||
if (userId) restoreQueue(userId);
|
if (userId) {
|
||||||
|
restoreQueue(userId);
|
||||||
|
// Not awaited: the cached preference levels playback until it lands.
|
||||||
|
void loadNormalization();
|
||||||
|
}
|
||||||
return {};
|
return {};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -50,7 +50,9 @@ vi.mock('$lib/api/admin', async () => {
|
|||||||
refetchMissingCovers: vi.fn().mockResolvedValue({ started: true }),
|
refetchMissingCovers: vi.fn().mockResolvedValue({ started: true }),
|
||||||
researchMissingArt: vi.fn().mockResolvedValue({ version: 1 }),
|
researchMissingArt: vi.fn().mockResolvedValue({ version: 1 }),
|
||||||
// LoudnessSettingsCard, rendered by the page and tested on its own.
|
// LoudnessSettingsCard, rendered by the page and tested on its own.
|
||||||
getLoudnessSettings: vi.fn().mockResolvedValue({ enabled: true, backfill_concurrency: 2 }),
|
getLoudnessSettings: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ enabled: true, backfill_concurrency: 2, leveled_cache_mb: 2048 }),
|
||||||
updateLoudnessSettings: vi.fn(),
|
updateLoudnessSettings: vi.fn(),
|
||||||
getLoudnessCoverage: vi.fn().mockResolvedValue({
|
getLoudnessCoverage: vi.fn().mockResolvedValue({
|
||||||
total: 0, measured: 0, silent: 0, unreadable: 0, pending: 0, enabled: true
|
total: 0, measured: 0, silent: 0, unreadable: 0, pending: 0, enabled: true
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
import MobileAppDownload from '$lib/components/MobileAppDownload.svelte';
|
import MobileAppDownload from '$lib/components/MobileAppDownload.svelte';
|
||||||
import ServerVersion from '$lib/components/ServerVersion.svelte';
|
import ServerVersion from '$lib/components/ServerVersion.svelte';
|
||||||
import ActiveSessions from '$lib/components/ActiveSessions.svelte';
|
import ActiveSessions from '$lib/components/ActiveSessions.svelte';
|
||||||
|
import NormalizationSetting from '$lib/components/NormalizationSetting.svelte';
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
@@ -341,6 +342,8 @@
|
|||||||
0 = off · most albums sound best at 0.
|
0 = off · most albums sound best at 0.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<NormalizationSetting />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="space-y-4 rounded border border-border bg-surface p-4">
|
<section class="space-y-4 rounded border border-border bg-surface p-4">
|
||||||
|
|||||||
@@ -25,6 +25,16 @@ vi.mock('$lib/api/listenbrainz', () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The Volume leveling setting loads on mount; keep it off the network.
|
||||||
|
vi.mock('$lib/api/normalization', async (importOriginal) => {
|
||||||
|
const mod = await importOriginal<typeof import('$lib/api/normalization')>();
|
||||||
|
return {
|
||||||
|
...mod,
|
||||||
|
getNormalization: vi.fn().mockResolvedValue(mod.DEFAULT_NORMALIZATION),
|
||||||
|
putNormalization: vi.fn()
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock('$lib/api/metrics', () => ({
|
vi.mock('$lib/api/metrics', () => ({
|
||||||
createRecommendationMetricsQuery: () => ({
|
createRecommendationMetricsQuery: () => ({
|
||||||
subscribe: (run: (v: unknown) => void) => {
|
subscribe: (run: (v: unknown) => void) => {
|
||||||
|
|||||||
@@ -11,6 +11,16 @@ vi.mock('$lib/api/listenbrainz', () => ({
|
|||||||
setListenBrainzEnabled: vi.fn()
|
setListenBrainzEnabled: vi.fn()
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// The Volume leveling setting loads on mount; keep it off the network.
|
||||||
|
vi.mock('$lib/api/normalization', async (importOriginal) => {
|
||||||
|
const mod = await importOriginal<typeof import('$lib/api/normalization')>();
|
||||||
|
return {
|
||||||
|
...mod,
|
||||||
|
getNormalization: vi.fn().mockResolvedValue(mod.DEFAULT_NORMALIZATION),
|
||||||
|
putNormalization: vi.fn()
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock('$lib/api/me', () => ({
|
vi.mock('$lib/api/me', () => ({
|
||||||
updateProfile: vi.fn(),
|
updateProfile: vi.fn(),
|
||||||
changePassword: vi.fn(),
|
changePassword: vi.fn(),
|
||||||
|
|||||||
Reference in New Issue
Block a user