Files
minstrel/internal/library/leveled_test.go
T
bvandeusenandClaude Opus 5.5 2e36e70268
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
feat(android): Sonos/UPnP queue plays leveled URLs, rendered a track ahead (M464 #5002)
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>
2026-10-06 19:38:21 -04:00

280 lines
10 KiB
Go

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)
}
}
// 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})
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)
}
}