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

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:
2026-10-06 19:31:07 -04:00
co-authored by Claude Opus 5.5
parent 92c3f9bdb8
commit f34423a0e0
19 changed files with 1095 additions and 34 deletions
+7 -1
View File
@@ -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) {
+20
View File
@@ -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
View File
@@ -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,
})
}
+168
View File
@@ -0,0 +1,168 @@
package api
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"net/http"
"os"
"strconv"
"time"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
)
// The leveled stream (M464 #5001): a track rendered with the user's loudness
// gain applied, for the Sonos and UPnP speakers that fetch their own audio.
// See internal/library/leveled.go for how it is rendered.
// SignLeveledStreamToken signs a leveled stream URL. The gain is part of what
// is signed, so a speaker's URL cannot be edited into a different render.
// The message cannot collide with SignStreamToken's "<id>|<exp>": a plain
// token does not open a leveled stream, nor the reverse.
func SignLeveledStreamToken(secret []byte, trackID string, exp int64, g library.LeveledGain) string {
mac := hmac.New(sha256.New, secret)
lim := 0
if g.Limiter {
lim = 1
}
_, _ = fmt.Fprintf(mac, "%s|%d|leveled|%d|%d", trackID, exp, g.CentiDB, lim)
return hex.EncodeToString(mac.Sum(nil))
}
// VerifyLeveledStreamToken checks a token from SignLeveledStreamToken, and
// that it has not expired.
func VerifyLeveledStreamToken(secret []byte, trackID string, exp int64, g library.LeveledGain, token string) bool {
if time.Now().Unix() > exp {
return false
}
return hmac.Equal([]byte(SignLeveledStreamToken(secret, trackID, exp, g)), []byte(token))
}
// leveledStreamPath is the leveled stream's path. It ends in .flac because
// Sonos reads the format from the URL's extension (task #610).
func leveledStreamPath(trackID pgtype.UUID) string {
return "/api/tracks/" + uuidToString(trackID) + "/leveled.flac"
}
// leveledQuery is the query string a leveled URL carries.
func leveledQuery(g library.LeveledGain, token string, exp int64) string {
lim := "0"
if g.Limiter {
lim = "1"
}
return "?g=" + strconv.Itoa(g.CentiDB) + "&lim=" + lim +
"&token=" + token + "&exp=" + strconv.FormatInt(exp, 10)
}
// leveledGainFor is the render request for one track under the user's
// preference. Unity when leveling is off or the track is unmeasured, which
// the caller answers with the plain stream.
func (h *handlers) leveledGainFor(ctx context.Context, userID, trackID pgtype.UUID, asAlbum bool) (library.LeveledGain, error) {
q := dbq.New(h.pool)
prefs, err := library.LoadNormalizationPrefs(ctx, q, userID)
if err != nil {
return library.LeveledGain{}, err
}
gains, err := library.ReplayGainForTracks(ctx, q, []pgtype.UUID{trackID})
if err != nil {
return library.LeveledGain{}, err
}
return library.NewLeveledGain(library.LeveledGainDB(prefs, gains[trackID], asAlbum), prefs.Boost), nil
}
// parseLeveledGain reads ?g= and ?lim= from a leveled URL.
func parseLeveledGain(r *http.Request) (library.LeveledGain, bool) {
c, err := strconv.Atoi(r.URL.Query().Get("g"))
if err != nil {
return library.LeveledGain{}, false
}
lim := r.URL.Query().Get("lim")
if lim != "0" && lim != "1" {
return library.LeveledGain{}, false
}
g := library.LeveledGain{CentiDB: c, Limiter: lim == "1"}
return g, g.Valid()
}
// handleGetLeveledStream implements GET /api/tracks/{id}/leveled.flac. It
// accepts a session, like the plain stream, or a leveled token: the gain in
// the query must be the one the token was signed for.
func (h *handlers) handleGetLeveledStream(w http.ResponseWriter, r *http.Request) {
rawID := chi.URLParam(r, "id")
g, ok := parseLeveledGain(r)
if !h.leveledAuthOk(r, rawID, g, ok) {
writeErr(w, apierror.ErrUnauthorized)
return
}
if !ok {
writeErr(w, apierror.BadRequest("invalid_gain", "g and lim must describe a valid gain"))
return
}
if h.leveled == nil {
writeErr(w, &apierror.Error{Status: http.StatusServiceUnavailable, Code: "leveling_unavailable",
Message: "leveled streams are not available on this server"})
return
}
track, apiErr := resolveByID(r, "id", dbq.New(h.pool).GetTrackByID, "track")
if apiErr != nil {
writeErr(w, apiErr)
return
}
path, err := h.leveled.Path(r.Context(), library.LeveledSource{
TrackID: rawID, Path: track.FilePath, DurationMs: track.DurationMs,
}, g)
switch {
case errors.Is(err, library.ErrLeveledSourceMissing):
writeErr(w, &apierror.Error{Status: http.StatusNotFound, Code: "not_found", Message: "track file not found"})
return
case r.Context().Err() != nil:
return // the speaker hung up; the render carries on for its retry
case err != nil:
writeErrWithLog(w, h.logger, "leveled stream: render failed", apierror.InternalMsg("render failed", err))
return
}
f, err := os.Open(path)
if err != nil {
// Evicted between render and open: rare, and the speaker retries.
writeErrWithLog(w, h.logger, "leveled stream: open render", apierror.InternalMsg("server error", err))
return
}
defer func() { _ = f.Close() }()
info, err := f.Stat()
if err != nil {
writeErrWithLog(w, h.logger, "leveled stream: stat render", apierror.InternalMsg("server error", err))
return
}
w.Header().Set("Content-Type", "audio/flac")
w.Header().Set("Accept-Ranges", "bytes")
w.Header().Set("Cache-Control", "private, max-age=86400")
http.ServeContent(w, r, "leveled.flac", info.ModTime(), f)
}
// leveledAuthOk mirrors streamAuthOk: a session, or a token signed over this
// track and this gain. A malformed gain fails the token path, since there is
// nothing it could have been signed over.
func (h *handlers) leveledAuthOk(r *http.Request, trackID string, g library.LeveledGain, gainOK bool) bool {
if _, ok := auth.UserFromContext(r.Context()); ok {
return true
}
if !gainOK {
return false
}
tok := r.URL.Query().Get("token")
exp, err := strconv.ParseInt(r.URL.Query().Get("exp"), 10, 64)
if tok == "" || err != nil {
return false
}
return VerifyLeveledStreamToken(h.streamSecret, trackID, exp, g, tok)
}
+161
View File
@@ -0,0 +1,161 @@
package api
import (
"bytes"
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
)
func TestLeveledStreamToken(t *testing.T) {
secret := []byte("leveled-secret")
exp := time.Now().Unix() + 3600
g := library.LeveledGain{CentiDB: -550}
tok := SignLeveledStreamToken(secret, "track-1", exp, g)
if !VerifyLeveledStreamToken(secret, "track-1", exp, g, tok) {
t.Fatal("round trip failed")
}
// The gain is part of what is signed: a speaker URL edited to another
// gain, or to switch the limiter on, no longer verifies.
if VerifyLeveledStreamToken(secret, "track-1", exp, library.LeveledGain{CentiDB: 1200}, tok) {
t.Error("token verified for a different gain")
}
if VerifyLeveledStreamToken(secret, "track-1", exp, library.LeveledGain{CentiDB: -550, Limiter: true}, tok) {
t.Error("token verified with the limiter switched on")
}
if VerifyLeveledStreamToken(secret, "track-2", exp, g, tok) {
t.Error("token verified for another track")
}
if VerifyLeveledStreamToken(secret, "track-1", time.Now().Unix()-1, g, SignLeveledStreamToken(secret, "track-1", time.Now().Unix()-1, g)) {
t.Error("expired token verified")
}
// Plain and leveled tokens are not interchangeable.
plain := SignStreamToken(secret, "track-1", exp)
if VerifyLeveledStreamToken(secret, "track-1", exp, library.LeveledGain{}, plain) {
t.Error("a plain stream token opened a leveled stream")
}
if VerifyStreamToken(secret, "track-1", exp, tok) {
t.Error("a leveled token opened the plain stream")
}
}
func TestParseLeveledGain(t *testing.T) {
for _, c := range []struct {
query string
want library.LeveledGain
ok bool
}{
{"g=-550&lim=0", library.LeveledGain{CentiDB: -550}, true},
{"g=320&lim=1", library.LeveledGain{CentiDB: 320, Limiter: true}, true},
{"g=1201&lim=0", library.LeveledGain{}, false},
{"g=abc&lim=0", library.LeveledGain{}, false},
{"g=100&lim=yes", library.LeveledGain{}, false},
{"lim=0", library.LeveledGain{}, false},
} {
got, ok := parseLeveledGain(httptest.NewRequest(http.MethodGet, "/x?"+c.query, nil))
if ok != c.ok || (ok && got != c.want) {
t.Errorf("%s: got %+v ok=%v, want %+v ok=%v", c.query, got, ok, c.want, c.ok)
}
}
}
func TestCastStreamToken_Leveled(t *testing.T) {
h, pool := testHandlers(t)
h.streamSecret = []byte("leveled-cast-secret")
r, err := library.NewLeveledRenderer(t.TempDir(), nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatal(err)
}
h.leveled = r
user := seedUser(t, pool, "lev", "pw", false)
track, _ := seedTrackForRemoveTest(t, h, "lev", "", "")
trackID := uuidToString(track.ID)
lufs, peak := float32(-12.5), float32(-1)
if err := dbq.New(pool).UpsertTrackLoudness(context.Background(), dbq.UpsertTrackLoudnessParams{
TrackID: track.ID, IntegratedLufs: &lufs, TruePeakDbtp: &peak, AnalysisVersion: 1,
}); err != nil {
t.Fatalf("seed loudness: %v", err)
}
mint := func(req castTokenRequest) castTokenResponse {
t.Helper()
body, _ := json.Marshal(req)
w := httptest.NewRecorder()
h.handleCastStreamToken(w, withUser(httptest.NewRequest(http.MethodPost, "/api/cast/stream-token", bytes.NewReader(body)), user))
if w.Code != http.StatusOK {
t.Fatalf("status %d: %s", w.Code, w.Body.String())
}
var resp castTokenResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatal(err)
}
return resp
}
// Default preference (auto, -18): the track measures -12.5 LUFS, so it is
// cut by 5.5 dB, and the URL and its token say exactly that.
resp := mint(castTokenRequest{TrackID: trackID, Level: true})
if !resp.Leveled || resp.MIME != "audio/flac" ||
!strings.Contains(resp.URL, "/api/tracks/"+trackID+"/leveled.flac?g=-550&lim=0&token=") {
t.Fatalf("leveled mint = %+v", resp)
}
if !VerifyLeveledStreamToken(h.streamSecret, trackID, resp.Exp, library.LeveledGain{CentiDB: -550}, resp.Token) {
t.Fatal("leveled token does not verify for the gain in the URL")
}
// Not asked for: the plain stream, as before.
if resp := mint(castTokenRequest{TrackID: trackID}); resp.Leveled || strings.Contains(resp.URL, "leveled") {
t.Fatalf("unleveled mint = %+v", resp)
}
// Leveling switched off: nothing to render, so the plain stream.
if _, err := library.SaveNormalizationPrefs(context.Background(), dbq.New(pool), user.ID,
library.NormalizationPrefs{Mode: "off", TargetLUFS: -18, Boost: "headroom"}); err != nil {
t.Fatal(err)
}
if resp := mint(castTokenRequest{TrackID: trackID, Level: true}); resp.Leveled {
t.Fatalf("mint with leveling off = %+v, want the plain stream", resp)
}
}
func TestGetLeveledStream_RefusesUnsignedGains(t *testing.T) {
h, _ := testHandlers(t)
h.streamSecret = []byte("leveled-get-secret")
router := chi.NewRouter()
router.Get("/api/tracks/{id}/leveled.flac", h.handleGetLeveledStream)
id := nonExistentTrackUUID
exp := time.Now().Unix() + 3600
tok := SignLeveledStreamToken(h.streamSecret, id, exp, library.LeveledGain{CentiDB: -300})
get := func(query string) int {
w := httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/tracks/"+id+"/leveled.flac?"+query, nil))
return w.Code
}
e := strconv.FormatInt(exp, 10)
for name, q := range map[string]string{
"no token": "g=-300&lim=0",
"edited gain": "g=1200&lim=0&token=" + tok + "&exp=" + e,
"limiter on": "g=-300&lim=1&token=" + tok + "&exp=" + e,
"invalid gain": "g=99999&lim=0&token=" + tok + "&exp=" + e,
} {
if code := get(q); code != http.StatusUnauthorized {
t.Errorf("%s: status %d, want 401", name, code)
}
}
// The signed gain gets past auth to the track lookup.
if code := get("g=-300&lim=0&token=" + tok + "&exp=" + e); code == http.StatusUnauthorized {
t.Errorf("the signed gain was refused")
}
}