M464 loudness leveling (steps 4–8), APK distribution and security baseline adoptions #136
@@ -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))
|
||||||
@@ -343,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
|
||||||
@@ -370,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,12 @@ 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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type castTokenResponse struct {
|
type castTokenResponse struct {
|
||||||
@@ -31,6 +38,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 +100,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 +124,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
|
||||||
|
// 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
|
// 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 +164,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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
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,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.
|
// 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
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user