feat(android): Sonos/UPnP queue plays leveled URLs, rendered a track ahead (M464 #5002)
release / go (push) Successful in 2m49s
release / web (push) Successful in 2m21s
release / govulncheck (push) Successful in 25s
release / integration (push) Successful in 5m54s
release / android (push) Successful in 8m10s
release / Build signed APK (releases and dev) (push) Successful in 8m35s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m42s
release / Verify release artifacts (tag releases only) (push) Skipped

Every URL the Sonos queue loader sends is minted with level=true and
the track's album-play verdict from its neighbours in the queue; the
server returns the plain stream when leveling is off or changes
nothing. The playing track and the one after it are rendered ahead,
and each time the renderer moves on, the next is.

Server: a mint no longer prerenders on its own. A queue load mints
every track, which would have started an ffmpeg render per track at
once. The request now carries prerender, and at most two prerenders
run at a time; past that they are dropped, since a fetch renders on
demand anyway.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 19:38:21 -04:00
co-authored by Claude Opus 5.5
parent f34423a0e0
commit 2e36e70268
9 changed files with 216 additions and 38 deletions
+10 -5
View File
@@ -26,6 +26,11 @@ type castTokenRequest struct {
// client holding the queue knows it.
Level bool `json:"level,omitempty"`
AsAlbum bool `json:"asAlbum,omitempty"`
// Prerender says the speaker will fetch this track soon: the current
// track at a queue load, or the next one as it starts. Only those are
// rendered ahead; a queue load mints every track and renders none of
// the rest until the speaker asks.
Prerender bool `json:"prerender,omitempty"`
}
type castTokenResponse struct {
@@ -138,11 +143,11 @@ func (h *handlers) handleCastStreamToken(w http.ResponseWriter, r *http.Request)
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)
if req.Prerender {
h.leveled.Prerender(library.LeveledSource{
TrackID: req.TrackID, Path: track.FilePath, DurationMs: track.DurationMs,
}, g)
}
}
}
+28 -9
View File
@@ -161,6 +161,11 @@ type LeveledRenderer struct {
group singleflight.Group
evictMu sync.Mutex
// prerenders bounds the background renders running at once. A fetch
// renders on demand regardless, so a prerender that finds no slot is
// dropped, not queued.
prerenders chan struct{}
// render and probe are ffmpeg and ffprobe; tests replace them.
render func(ctx context.Context, src, dst string, g LeveledGain, s leveledSource) error
probe func(ctx context.Context, src string) (leveledSource, error)
@@ -172,11 +177,12 @@ func NewLeveledRenderer(dir string, settings *LoudnessSettingsService, logger *s
return nil, fmt.Errorf("leveled cache: %w", err)
}
return &LeveledRenderer{
dir: dir,
settings: settings,
logger: logger,
render: ffmpegRenderLeveled,
probe: ffprobeLeveledSource,
dir: dir,
settings: settings,
logger: logger,
prerenders: make(chan struct{}, maxLeveledPrerenders),
render: ffmpegRenderLeveled,
probe: ffprobeLeveledSource,
}, nil
}
@@ -217,17 +223,30 @@ func (r *LeveledRenderer) Path(ctx context.Context, src LeveledSource, g Leveled
}
}
// 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) {
// maxLeveledPrerenders is how many prerenders may run at once. A speaker
// needs the track it is about to play and the one after; more than that is a
// client asking for too much, and the excess is dropped.
const maxLeveledPrerenders = 2
// Prerender starts rendering src at g in the background, so the speaker's
// fetch finds it ready. It reports false, and does nothing, when the
// prerender slots are full.
func (r *LeveledRenderer) Prerender(src LeveledSource, g LeveledGain) bool {
select {
case r.prerenders <- struct{}{}:
default:
r.logger.Debug("leveled: prerender dropped, slots full", "track", src.TrackID)
return false
}
go func() {
defer func() { <-r.prerenders }()
ctx, cancel := context.WithTimeout(context.Background(), loudnessTimeout(src.DurationMs))
defer cancel()
if _, err := r.Path(ctx, src, g); err != nil {
r.logger.Warn("leveled: prerender failed", "track", src.TrackID, "err", err)
}
}()
return true
}
func (r *LeveledRenderer) renderTo(ctx context.Context, src, dst string, g LeveledGain) error {
+35
View File
@@ -195,6 +195,41 @@ func TestLeveledRenderer_CoalescesAndCaches(t *testing.T) {
}
}
// Prerenders past the slot limit are dropped rather than piling up ffmpeg
// processes; a slot frees when its render ends.
func TestLeveledRenderer_PrerenderSlots(t *testing.T) {
r, renders, _ := testRenderer(t, 2048, 10)
gate := make(chan struct{})
r.render = func(_ context.Context, _, dst string, _ LeveledGain, _ leveledSource) error {
<-gate
renders.Add(1)
return os.WriteFile(dst, []byte("flac"), 0o600)
}
g := LeveledGain{CentiDB: -300}
for i := range maxLeveledPrerenders {
if !r.Prerender(writeSource(t, string(rune('a'+i))), g) {
t.Fatalf("prerender %d refused with slots free", i)
}
}
if r.Prerender(writeSource(t, "z"), g) {
t.Fatal("a prerender past the limit was accepted")
}
close(gate)
deadline := time.Now().Add(5 * time.Second)
for !r.Prerender(writeSource(t, "y"), g) {
if time.Now().After(deadline) {
t.Fatal("no slot freed after the renders finished")
}
time.Sleep(10 * time.Millisecond)
}
for renders.Load() != maxLeveledPrerenders+1 {
if time.Now().After(deadline) {
t.Fatalf("renders = %d, want %d", renders.Load(), maxLeveledPrerenders+1)
}
time.Sleep(10 * time.Millisecond)
}
}
func TestLeveledRenderer_MissingSource(t *testing.T) {
r, _, _ := testRenderer(t, 2048, 10)
_, err := r.Path(context.Background(), LeveledSource{TrackID: "x", Path: "/nonexistent/x.flac"}, LeveledGain{CentiDB: 100})