feat: leveled FLAC stream for Sonos/UPnP speakers (M464 #5001)
release / go (push) Successful in 2m17s
release / govulncheck (push) Successful in 26s
release / web (push) Successful in 2m4s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / integration (push) Canceled after 4m39s
release / android (push) Canceled after 2m53s
release / Build signed APK (releases and dev) (push) Canceled after 2m24s
release / go (push) Successful in 2m17s
release / govulncheck (push) Successful in 26s
release / web (push) Successful in 2m4s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / integration (push) Canceled after 4m39s
release / android (push) Canceled after 2m53s
release / Build signed APK (releases and dev) (push) Canceled after 2m24s
Speakers fetch their own audio, so the phone cannot level it. A cast
token minted with level=true (and the client's asAlbum, which only the
queue holder knows) now returns GET /api/tracks/{id}/leveled.flac: the
track rendered by ffmpeg at the user's gain (volume=XdB, plus
alimiter at -1 dBFS for a limiter-mode boost), metadata stripped, FLAC
at 16 or 24 bits and at most 48 kHz. The gain is computed server-side
from the user's preference and the stored loudness, carried as
?g=<centi-dB>&lim=0|1 and signed into the token, so an edited URL does
not verify. Unity gains get the plain stream.
Renders are written beside the cache file and renamed in, keyed by the
source's size and mtime, coalesced per file (singleflight, detached
from the requesting speaker so a retry finds the render running),
started at mint time so the fetch finds them ready, and evicted least
recently used past leveled_cache_mb, a new admin setting (migration
0068, Loudness analysis card).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user