feat: leveled FLAC stream for Sonos/UPnP speakers (M464 #5001)
release / go (push) Successful in 2m17s
release / govulncheck (push) Successful in 26s
release / web (push) Successful in 2m4s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / integration (push) Canceled after 4m39s
release / android (push) Canceled after 2m53s
release / Build signed APK (releases and dev) (push) Canceled after 2m24s
release / go (push) Successful in 2m17s
release / govulncheck (push) Successful in 26s
release / web (push) Successful in 2m4s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / integration (push) Canceled after 4m39s
release / android (push) Canceled after 2m53s
release / Build signed APK (releases and dev) (push) Canceled after 2m24s
Speakers fetch their own audio, so the phone cannot level it. A cast
token minted with level=true (and the client's asAlbum, which only the
queue holder knows) now returns GET /api/tracks/{id}/leveled.flac: the
track rendered by ffmpeg at the user's gain (volume=XdB, plus
alimiter at -1 dBFS for a limiter-mode boost), metadata stripped, FLAC
at 16 or 24 bits and at most 48 kHz. The gain is computed server-side
from the user's preference and the stored loudness, carried as
?g=<centi-dB>&lim=0|1 and signed into the token, so an edited URL does
not verify. Unity gains get the plain stream.
Renders are written beside the cache file and renamed in, keyed by the
source's size and mtime, coalesced per file (singleflight, detached
from the requesting speaker so a retry finds the render running),
started at mint time so the fetch finds them ready, and evicted least
recently used past leveled_cache_mb, a new admin setting (migration
0068, Loudness analysis card).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,7 @@ require (
|
||||
github.com/jackc/pgx/v5 v5.9.2
|
||||
github.com/stretchr/testify v1.11.1
|
||||
golang.org/x/crypto v0.51.0
|
||||
golang.org/x/sync v0.21.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
@@ -25,7 +26,6 @@ require (
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
golang.org/x/sync v0.21.0 // indirect
|
||||
golang.org/x/sys v0.44.0 // indirect
|
||||
golang.org/x/text v0.39.0 // indirect
|
||||
)
|
||||
|
||||
@@ -46,10 +46,15 @@ func (h *handlers) handleGetLoudnessCoverage(w http.ResponseWriter, r *http.Requ
|
||||
type loudnessSettingsBody struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
BackfillConcurrency int32 `json:"backfill_concurrency"`
|
||||
LeveledCacheMB int32 `json:"leveled_cache_mb"`
|
||||
}
|
||||
|
||||
func loudnessSettingsBodyOf(s library.LoudnessSettings) loudnessSettingsBody {
|
||||
return loudnessSettingsBody{Enabled: s.Enabled, BackfillConcurrency: s.BackfillConcurrency}
|
||||
return loudnessSettingsBody{
|
||||
Enabled: s.Enabled,
|
||||
BackfillConcurrency: s.BackfillConcurrency,
|
||||
LeveledCacheMB: s.LeveledCacheMB,
|
||||
}
|
||||
}
|
||||
|
||||
// handleGetLoudnessSettings implements GET /api/admin/library/loudness-settings.
|
||||
@@ -69,6 +74,7 @@ func (h *handlers) handleUpdateLoudnessSettings(w http.ResponseWriter, r *http.R
|
||||
saved, err := h.loudnessSettings.Set(r.Context(), library.LoudnessSettings{
|
||||
Enabled: req.Enabled,
|
||||
BackfillConcurrency: req.BackfillConcurrency,
|
||||
LeveledCacheMB: req.LeveledCacheMB,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, library.ErrLoudnessSettingOutOfRange) {
|
||||
|
||||
@@ -7,6 +7,7 @@ package api
|
||||
import (
|
||||
"log/slog"
|
||||
"math/rand"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
@@ -66,6 +67,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
||||
reacqSettings: reacqSettings,
|
||||
fingerprintSettings: fpSettings,
|
||||
loudnessSettings: loudSettings,
|
||||
leveled: newLeveledRenderer(dataDir, loudSettings, logger),
|
||||
librarySize: recommendation.NewLibrarySize(nil),
|
||||
loginGuard: auth.NewLoginGuard(),
|
||||
setupToken: setupToken,
|
||||
@@ -96,6 +98,9 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
||||
// audio format from the path. The {ext} param is consumed by chi
|
||||
// and ignored by the handler (which keys off {id}). See task #610.
|
||||
api.With(auth.OptionalUser(pool, logger)).Get("/tracks/{id}/stream.{ext}", h.handleGetStream)
|
||||
// The leveled stream for Sonos/UPnP (M464 #5001): session or a
|
||||
// leveled token, like the plain stream.
|
||||
api.With(auth.OptionalUser(pool, logger)).Get("/tracks/{id}/leveled.flac", h.handleGetLeveledStream)
|
||||
|
||||
api.Group(func(authed chi.Router) {
|
||||
authed.Use(auth.RequireUser(pool, netSettings.Hops))
|
||||
@@ -343,6 +348,10 @@ type handlers struct {
|
||||
// loudnessSettings is the loudness analysis policy (M464 #4995), the same
|
||||
// instance the loudness backfill reads. Nil serves the defaults.
|
||||
loudnessSettings *library.LoudnessSettingsService
|
||||
// leveled renders the leveled streams handed to Sonos/UPnP speakers
|
||||
// (M464 #5001). Nil when its cache directory cannot be made: a level
|
||||
// request then gets the plain stream.
|
||||
leveled *library.LeveledRenderer
|
||||
// setupToken must accompany the first registration while no users exist
|
||||
// (see auth.SetupToken). requireSetupToken is set by Mount, the only
|
||||
// production constructor; tests that build handlers directly leave it
|
||||
@@ -370,3 +379,14 @@ type handlers struct {
|
||||
// anything a client mints), which is the desired slice-1 default.
|
||||
streamSecret []byte
|
||||
}
|
||||
|
||||
// newLeveledRenderer makes the leveled-stream renderer, caching under the
|
||||
// data directory. A failure is logged and leaves leveling off for speakers.
|
||||
func newLeveledRenderer(dataDir string, settings *library.LoudnessSettingsService, logger *slog.Logger) *library.LeveledRenderer {
|
||||
r, err := library.NewLeveledRenderer(filepath.Join(dataDir, "leveled-cache"), settings, logger)
|
||||
if err != nil {
|
||||
logger.Error("api: leveled streams unavailable", "err", err)
|
||||
return nil
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
+41
-10
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -19,6 +20,12 @@ const (
|
||||
type castTokenRequest struct {
|
||||
TrackID string `json:"trackId"`
|
||||
ExpSeconds int `json:"expSeconds,omitempty"`
|
||||
// Level asks for the leveled stream (M464 #5001): the track rendered at
|
||||
// the user's loudness gain. AsAlbum says the track is playing as part of
|
||||
// its album in order, which picks album gain in auto mode; only the
|
||||
// client holding the queue knows it.
|
||||
Level bool `json:"level,omitempty"`
|
||||
AsAlbum bool `json:"asAlbum,omitempty"`
|
||||
}
|
||||
|
||||
type castTokenResponse struct {
|
||||
@@ -31,6 +38,10 @@ type castTokenResponse struct {
|
||||
// `<res protocolInfo>` and `<dc:title>` without a follow-up round trip.
|
||||
MIME string `json:"mime"`
|
||||
Title string `json:"title"`
|
||||
// Leveled is true when URL is the leveled stream. A level request still
|
||||
// gets the plain stream when there is nothing to change: leveling off,
|
||||
// the track not yet measured, or a gain of 0.
|
||||
Leveled bool `json:"leveled"`
|
||||
}
|
||||
|
||||
// mimeForFormat returns the audio MIME type for a cast (Sonos/UPnP) URL.
|
||||
@@ -89,7 +100,8 @@ func extForFormat(format string) string {
|
||||
// Part of the output-picker UPnP slice. See
|
||||
// docs/superpowers/specs/2026-06-03-android-output-picker-upnp-design.md.
|
||||
func (h *handlers) handleCastStreamToken(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := requireUser(w, r); !ok {
|
||||
user, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req castTokenRequest
|
||||
@@ -112,6 +124,27 @@ func (h *handlers) handleCastStreamToken(w http.ResponseWriter, r *http.Request)
|
||||
expSec := clampExpSeconds(req.ExpSeconds)
|
||||
exp := time.Now().Add(time.Duration(expSec) * time.Second).Unix()
|
||||
token := SignStreamToken(h.streamSecret, req.TrackID, exp)
|
||||
path := streamURLWithExt(trackUUID, extForFormat(track.FileFormat)) +
|
||||
"?token=" + token + "&exp=" + strconv.FormatInt(exp, 10)
|
||||
mime := mimeForFormat(track.FileFormat)
|
||||
leveled := false
|
||||
if req.Level && h.leveled != nil {
|
||||
g, err := h.leveledGainFor(r.Context(), user.ID, trackUUID, req.AsAlbum)
|
||||
if err != nil {
|
||||
// The plain stream still plays; only the leveling is lost.
|
||||
h.logger.Warn("cast token: leveled gain lookup failed", "track", req.TrackID, "err", err)
|
||||
} else if !g.Unity() {
|
||||
token = SignLeveledStreamToken(h.streamSecret, req.TrackID, exp, g)
|
||||
path = leveledStreamPath(trackUUID) + leveledQuery(g, token, exp)
|
||||
mime = "audio/flac"
|
||||
leveled = true
|
||||
// The speaker fetches the URL shortly; start rendering now so
|
||||
// the fetch finds the file ready.
|
||||
h.leveled.Prerender(library.LeveledSource{
|
||||
TrackID: req.TrackID, Path: track.FilePath, DurationMs: track.DurationMs,
|
||||
}, g)
|
||||
}
|
||||
}
|
||||
|
||||
// Behind a TLS-terminating reverse proxy, r.TLS is nil even though
|
||||
// the public-facing URL is https://. UPnP devices (Sonos especially)
|
||||
@@ -131,18 +164,16 @@ func (h *handlers) handleCastStreamToken(w http.ResponseWriter, r *http.Request)
|
||||
if h := r.Header.Get("X-Forwarded-Host"); h != "" {
|
||||
host = h
|
||||
}
|
||||
// Include the file extension in the path so Sonos's URL probe sees a
|
||||
// The path carries a file extension so Sonos's URL probe sees a
|
||||
// recognizable audio file. Without it, Sonos reports TrackDuration=0
|
||||
// and seeks past 0s land "after the end" -> early track-skip.
|
||||
url := scheme + "://" + host + streamURLWithExt(trackUUID, extForFormat(track.FileFormat)) +
|
||||
"?token=" + token + "&exp=" + strconv.FormatInt(exp, 10)
|
||||
|
||||
writeJSON(w, http.StatusOK, castTokenResponse{
|
||||
Token: token,
|
||||
Exp: exp,
|
||||
URL: url,
|
||||
MIME: mimeForFormat(track.FileFormat),
|
||||
Title: track.Title,
|
||||
Token: token,
|
||||
Exp: exp,
|
||||
URL: scheme + "://" + host + path,
|
||||
MIME: mime,
|
||||
Title: track.Title,
|
||||
Leveled: leveled,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -127,7 +127,7 @@ func (q *Queries) GetLoudnessCoverage(ctx context.Context, currentVersion int16)
|
||||
}
|
||||
|
||||
const getLoudnessSettings = `-- name: GetLoudnessSettings :one
|
||||
SELECT id, enabled, backfill_concurrency, updated_at FROM loudness_settings WHERE id = true
|
||||
SELECT id, enabled, backfill_concurrency, updated_at, leveled_cache_mb FROM loudness_settings WHERE id = true
|
||||
`
|
||||
|
||||
func (q *Queries) GetLoudnessSettings(ctx context.Context) (LoudnessSetting, error) {
|
||||
@@ -138,6 +138,7 @@ func (q *Queries) GetLoudnessSettings(ctx context.Context) (LoudnessSetting, err
|
||||
&i.Enabled,
|
||||
&i.BackfillConcurrency,
|
||||
&i.UpdatedAt,
|
||||
&i.LeveledCacheMb,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -388,26 +389,29 @@ const updateLoudnessSettings = `-- name: UpdateLoudnessSettings :one
|
||||
UPDATE loudness_settings
|
||||
SET enabled = $1,
|
||||
backfill_concurrency = $2,
|
||||
leveled_cache_mb = $3,
|
||||
updated_at = now()
|
||||
WHERE id = true
|
||||
RETURNING id, enabled, backfill_concurrency, updated_at
|
||||
RETURNING id, enabled, backfill_concurrency, updated_at, leveled_cache_mb
|
||||
`
|
||||
|
||||
type UpdateLoudnessSettingsParams struct {
|
||||
Enabled bool
|
||||
BackfillConcurrency int32
|
||||
LeveledCacheMb int32
|
||||
}
|
||||
|
||||
// Whole-row write from the admin card; migration 0065's CHECK is the backstop
|
||||
// Whole-row write from the admin card; the migrations' CHECKs are the backstop
|
||||
// behind the service's own validation.
|
||||
func (q *Queries) UpdateLoudnessSettings(ctx context.Context, arg UpdateLoudnessSettingsParams) (LoudnessSetting, error) {
|
||||
row := q.db.QueryRow(ctx, updateLoudnessSettings, arg.Enabled, arg.BackfillConcurrency)
|
||||
row := q.db.QueryRow(ctx, updateLoudnessSettings, arg.Enabled, arg.BackfillConcurrency, arg.LeveledCacheMb)
|
||||
var i LoudnessSetting
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Enabled,
|
||||
&i.BackfillConcurrency,
|
||||
&i.UpdatedAt,
|
||||
&i.LeveledCacheMb,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
@@ -432,6 +432,7 @@ type LoudnessSetting struct {
|
||||
Enabled bool
|
||||
BackfillConcurrency int32
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
LeveledCacheMb int32
|
||||
}
|
||||
|
||||
type MissingReacquisition struct {
|
||||
|
||||
@@ -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);
|
||||
@@ -67,11 +67,12 @@ SELECT count(*)::bigint AS total,
|
||||
SELECT * FROM loudness_settings WHERE id = true;
|
||||
|
||||
-- name: UpdateLoudnessSettings :one
|
||||
-- Whole-row write from the admin card; migration 0065's CHECK is the backstop
|
||||
-- Whole-row write from the admin card; the migrations' CHECKs are the backstop
|
||||
-- behind the service's own validation.
|
||||
UPDATE loudness_settings
|
||||
SET enabled = sqlc.arg(enabled),
|
||||
backfill_concurrency = sqlc.arg(backfill_concurrency),
|
||||
leveled_cache_mb = sqlc.arg(leveled_cache_mb),
|
||||
updated_at = now()
|
||||
WHERE id = true
|
||||
RETURNING *;
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
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
|
||||
|
||||
// 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,
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
// Prerender starts rendering src at g in the background, so the renderer's
|
||||
// fetch finds it ready. Used when a leveled URL is handed out: the speaker
|
||||
// asks for the next track shortly before it plays.
|
||||
func (r *LeveledRenderer) Prerender(src LeveledSource, g LeveledGain) {
|
||||
go func() {
|
||||
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)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
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,244 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeveledRenderer_MissingSource(t *testing.T) {
|
||||
r, _, _ := testRenderer(t, 2048, 10)
|
||||
_, err := r.Path(context.Background(), LeveledSource{TrackID: "x", Path: "/nonexistent/x.flac"}, LeveledGain{CentiDB: 100})
|
||||
if !errors.Is(err, ErrLeveledSourceMissing) {
|
||||
t.Fatalf("err = %v, want ErrLeveledSourceMissing", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeveledRenderer_FailedRenderLeavesNothing(t *testing.T) {
|
||||
r, _, _ := testRenderer(t, 2048, 10)
|
||||
r.render = func(_ context.Context, _, dst string, _ LeveledGain, _ leveledSource) error {
|
||||
_ = os.WriteFile(dst, []byte("half"), 0o600)
|
||||
return errors.New("ffmpeg exited 1")
|
||||
}
|
||||
if _, err := r.Path(context.Background(), writeSource(t, "a"), LeveledGain{CentiDB: 100}); err == nil {
|
||||
t.Fatal("a failed render reported success")
|
||||
}
|
||||
if entries, _ := os.ReadDir(r.dir); len(entries) != 0 {
|
||||
t.Fatalf("a failed render left %d files behind", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeveledRenderer_EvictsLeastRecentlyUsed(t *testing.T) {
|
||||
// 1 MB cap, 400 KB renders: the third render pushes out the oldest.
|
||||
r, _, _ := testRenderer(t, 1, 400<<10)
|
||||
a, b, c := writeSource(t, "a"), writeSource(t, "b"), writeSource(t, "c")
|
||||
g := LeveledGain{CentiDB: 100}
|
||||
pa, _ := r.Path(context.Background(), a, g)
|
||||
past := time.Now().Add(-time.Hour)
|
||||
_ = os.Chtimes(pa, past, past)
|
||||
pb, _ := r.Path(context.Background(), b, g)
|
||||
_ = os.Chtimes(pb, past.Add(time.Minute), past.Add(time.Minute))
|
||||
// Using a again makes b the least recently used.
|
||||
if _, err := r.Path(context.Background(), a, g); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pc, _ := r.Path(context.Background(), c, g)
|
||||
|
||||
var left []string
|
||||
entries, _ := os.ReadDir(r.dir)
|
||||
for _, e := range entries {
|
||||
left = append(left, filepath.Join(r.dir, e.Name()))
|
||||
}
|
||||
if slices.Contains(left, pb) || !slices.Contains(left, pa) || !slices.Contains(left, pc) {
|
||||
t.Fatalf("cache after eviction = %v; want a and c kept, b (least recently used) gone", left)
|
||||
}
|
||||
}
|
||||
@@ -22,17 +22,27 @@ type LoudnessSettings struct {
|
||||
// values, so normalization keeps working for them.
|
||||
Enabled bool
|
||||
BackfillConcurrency int32
|
||||
// LeveledCacheMB caps the disk the leveled-stream renders for Sonos and
|
||||
// UPnP speakers may use (#5001); the least recently played go first.
|
||||
LeveledCacheMB int32
|
||||
// UpdatedAt is set by the database; ignored by Set.
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// DefaultLoudnessSettings mirrors migration 0065's column defaults, so a
|
||||
// DefaultLoudnessSettings mirrors migrations 0065 and 0068's column defaults, so a
|
||||
// database that cannot be read still analyzes the way a fresh install does.
|
||||
var DefaultLoudnessSettings = LoudnessSettings{
|
||||
Enabled: true,
|
||||
BackfillConcurrency: loudnessBackfillConcurrency,
|
||||
LeveledCacheMB: 2048,
|
||||
}
|
||||
|
||||
// The leveled-stream cache's bounds, as migration 0068's CHECK has them.
|
||||
const (
|
||||
minLeveledCacheMB = 256
|
||||
maxLeveledCacheMB = 65536
|
||||
)
|
||||
|
||||
// ErrLoudnessSettingOutOfRange is returned by Set for a value migration 0065's
|
||||
// CHECK would reject, so the API answers 400 naming the field.
|
||||
var ErrLoudnessSettingOutOfRange = errors.New("loudness setting out of range")
|
||||
@@ -78,6 +88,7 @@ func (s *LoudnessSettingsService) Set(ctx context.Context, in LoudnessSettings)
|
||||
row, err := dbq.New(s.pool).UpdateLoudnessSettings(ctx, dbq.UpdateLoudnessSettingsParams{
|
||||
Enabled: in.Enabled,
|
||||
BackfillConcurrency: in.BackfillConcurrency,
|
||||
LeveledCacheMb: in.LeveledCacheMB,
|
||||
})
|
||||
if err != nil {
|
||||
return LoudnessSettings{}, fmt.Errorf("loudness settings: save: %w", err)
|
||||
@@ -94,6 +105,10 @@ func validateLoudnessSettings(in LoudnessSettings) error {
|
||||
return fmt.Errorf("%w: backfill_concurrency must be %d-%d",
|
||||
ErrLoudnessSettingOutOfRange, minBackfillConcurrency, maxBackfillConcurrency)
|
||||
}
|
||||
if in.LeveledCacheMB < minLeveledCacheMB || in.LeveledCacheMB > maxLeveledCacheMB {
|
||||
return fmt.Errorf("%w: leveled_cache_mb must be %d-%d",
|
||||
ErrLoudnessSettingOutOfRange, minLeveledCacheMB, maxLeveledCacheMB)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -101,6 +116,7 @@ func loudnessSettingsFromRow(row dbq.LoudnessSetting) LoudnessSettings {
|
||||
return LoudnessSettings{
|
||||
Enabled: row.Enabled,
|
||||
BackfillConcurrency: row.BackfillConcurrency,
|
||||
LeveledCacheMB: row.LeveledCacheMb,
|
||||
UpdatedAt: row.UpdatedAt.Time,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,14 +233,27 @@ func TestBackfillLoudnessResult_Add(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestValidateLoudnessSettings(t *testing.T) {
|
||||
for _, n := range []int32{minBackfillConcurrency, maxBackfillConcurrency} {
|
||||
if err := validateLoudnessSettings(LoudnessSettings{BackfillConcurrency: n}); err != nil {
|
||||
t.Errorf("concurrency %d rejected: %v", n, err)
|
||||
with := func(concurrency, cacheMB int32) LoudnessSettings {
|
||||
s := DefaultLoudnessSettings
|
||||
s.BackfillConcurrency, s.LeveledCacheMB = concurrency, cacheMB
|
||||
return s
|
||||
}
|
||||
for _, s := range []LoudnessSettings{
|
||||
with(minBackfillConcurrency, minLeveledCacheMB),
|
||||
with(maxBackfillConcurrency, maxLeveledCacheMB),
|
||||
} {
|
||||
if err := validateLoudnessSettings(s); err != nil {
|
||||
t.Errorf("%+v rejected: %v", s, err)
|
||||
}
|
||||
}
|
||||
for _, n := range []int32{0, maxBackfillConcurrency + 1} {
|
||||
if err := validateLoudnessSettings(LoudnessSettings{BackfillConcurrency: n}); !errors.Is(err, ErrLoudnessSettingOutOfRange) {
|
||||
t.Errorf("concurrency %d: err = %v, want ErrLoudnessSettingOutOfRange", n, err)
|
||||
for _, s := range []LoudnessSettings{
|
||||
with(0, minLeveledCacheMB),
|
||||
with(maxBackfillConcurrency+1, minLeveledCacheMB),
|
||||
with(minBackfillConcurrency, minLeveledCacheMB-1),
|
||||
with(minBackfillConcurrency, maxLeveledCacheMB+1),
|
||||
} {
|
||||
if err := validateLoudnessSettings(s); !errors.Is(err, ErrLoudnessSettingOutOfRange) {
|
||||
t.Errorf("%+v: err = %v, want ErrLoudnessSettingOutOfRange", s, err)
|
||||
}
|
||||
}
|
||||
var nilSvc *LoudnessSettingsService
|
||||
|
||||
@@ -386,6 +386,8 @@ export async function getLoudnessCoverage(): Promise<LoudnessCoverage> {
|
||||
export type LoudnessSettings = {
|
||||
enabled: boolean;
|
||||
backfill_concurrency: number;
|
||||
/** Disk for the leveled copies rendered for Sonos/UPnP speakers (#5001). */
|
||||
leveled_cache_mb: number;
|
||||
};
|
||||
|
||||
export async function getLoudnessSettings(): Promise<LoudnessSettings> {
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
import { pushToast } from '$lib/stores/toast.svelte';
|
||||
|
||||
// Loudness analysis (#4995): the background measurement that loudness
|
||||
// normalization levels playback from, its progress, and its two knobs.
|
||||
// normalization levels playback from, its progress, and its knobs. The
|
||||
// cache size bounds the leveled copies rendered for speakers (#5001).
|
||||
|
||||
let saved = $state<LoudnessSettings | null>(null);
|
||||
let form = $state<LoudnessSettings | null>(null);
|
||||
@@ -26,6 +27,13 @@
|
||||
form.backfill_concurrency >= 1 &&
|
||||
form.backfill_concurrency <= 8
|
||||
);
|
||||
const cacheOk = $derived(
|
||||
!!form &&
|
||||
Number.isInteger(form.leveled_cache_mb) &&
|
||||
form.leveled_cache_mb >= 256 &&
|
||||
form.leveled_cache_mb <= 65536
|
||||
);
|
||||
const valid = $derived(concurrencyOk && cacheOk);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
@@ -55,7 +63,7 @@
|
||||
});
|
||||
|
||||
async function save() {
|
||||
if (!form || !concurrencyOk) return;
|
||||
if (!form || !valid) return;
|
||||
saving = true;
|
||||
try {
|
||||
saved = await updateLoudnessSettings(form);
|
||||
@@ -151,10 +159,26 @@
|
||||
/>
|
||||
</label>
|
||||
|
||||
{#if !concurrencyOk}
|
||||
<p class="text-xs text-action-destructive" data-testid="settings-problems">
|
||||
Files analyzed at once must be a whole number from 1 to 8.
|
||||
</p>
|
||||
<label class="block">
|
||||
<span class="text-sm text-text-primary">Speaker cache (MB)</span>
|
||||
<span class="block text-xs text-text-secondary">
|
||||
Leveled copies for Sonos and UPnP speakers. The least recently played go first.
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
min="256"
|
||||
max="65536"
|
||||
step="256"
|
||||
bind:value={form.leveled_cache_mb}
|
||||
class={inputClass}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{#if !valid}
|
||||
<div class="text-xs text-action-destructive" data-testid="settings-problems">
|
||||
{#if !concurrencyOk}<p>Files analyzed at once must be a whole number from 1 to 8.</p>{/if}
|
||||
{#if !cacheOk}<p>Speaker cache must be a whole number of MB from 256 to 65536.</p>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex justify-end">
|
||||
@@ -163,7 +187,7 @@
|
||||
class="rounded-md bg-action-secondary px-4 py-2 text-sm text-action-fg hover:opacity-90
|
||||
focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent
|
||||
disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={!dirty || saving || !concurrencyOk}
|
||||
disabled={!dirty || saving || !valid}
|
||||
onclick={save}
|
||||
>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
|
||||
@@ -14,7 +14,7 @@ import LoudnessSettingsCard from './LoudnessSettingsCard.svelte';
|
||||
import { getLoudnessCoverage, getLoudnessSettings, updateLoudnessSettings } from '$lib/api/admin';
|
||||
import { pushToast } from '$lib/stores/toast.svelte';
|
||||
|
||||
const base: LoudnessSettings = { enabled: true, backfill_concurrency: 2 };
|
||||
const base: LoudnessSettings = { enabled: true, backfill_concurrency: 2, leveled_cache_mb: 2048 };
|
||||
const coverage: LoudnessCoverage = {
|
||||
total: 1200,
|
||||
measured: 900,
|
||||
@@ -68,7 +68,11 @@ describe('LoudnessSettingsCard', () => {
|
||||
await fireEvent.click(saveButton());
|
||||
|
||||
await waitFor(() =>
|
||||
expect(updateLoudnessSettings).toHaveBeenCalledWith({ enabled: true, backfill_concurrency: 4 })
|
||||
expect(updateLoudnessSettings).toHaveBeenCalledWith({
|
||||
enabled: true,
|
||||
backfill_concurrency: 4,
|
||||
leveled_cache_mb: 2048
|
||||
})
|
||||
);
|
||||
await waitFor(() => expect(pushToast).toHaveBeenCalledWith('Loudness analysis settings saved.'));
|
||||
// The gauge is read again after a save: switching analysis on or off changes it.
|
||||
@@ -84,6 +88,16 @@ describe('LoudnessSettingsCard', () => {
|
||||
expect(saveButton()).toHaveProperty('disabled', true);
|
||||
});
|
||||
|
||||
test('the speaker cache size is bounded too', async () => {
|
||||
await renderCard();
|
||||
const cache = screen.getByRole('spinbutton', { name: /speaker cache/i });
|
||||
await fireEvent.input(cache, { target: { value: '100' } });
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('settings-problems').textContent).toMatch(/256 to 65536/)
|
||||
);
|
||||
expect(saveButton()).toHaveProperty('disabled', true);
|
||||
});
|
||||
|
||||
test('a failed load offers a retry', async () => {
|
||||
vi.mocked(getLoudnessSettings).mockRejectedValue(new Error('boom'));
|
||||
vi.mocked(getLoudnessCoverage).mockResolvedValue(coverage);
|
||||
|
||||
@@ -50,7 +50,9 @@ vi.mock('$lib/api/admin', async () => {
|
||||
refetchMissingCovers: vi.fn().mockResolvedValue({ started: true }),
|
||||
researchMissingArt: vi.fn().mockResolvedValue({ version: 1 }),
|
||||
// LoudnessSettingsCard, rendered by the page and tested on its own.
|
||||
getLoudnessSettings: vi.fn().mockResolvedValue({ enabled: true, backfill_concurrency: 2 }),
|
||||
getLoudnessSettings: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ enabled: true, backfill_concurrency: 2, leveled_cache_mb: 2048 }),
|
||||
updateLoudnessSettings: vi.fn(),
|
||||
getLoudnessCoverage: vi.fn().mockResolvedValue({
|
||||
total: 0, measured: 0, silent: 0, unreadable: 0, pending: 0, enabled: true
|
||||
|
||||
Reference in New Issue
Block a user