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