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>
245 lines
8.8 KiB
Go
245 lines
8.8 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)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|