package library import ( "context" "io" "log/slog" "math" "path/filepath" "testing" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync" ) // histAt builds a histogram of n blocks all at one loudness. func histAt(lufs float64, n int32) blockHistogram { return blockHistogram{ start: int16(math.Round((lufs - loudnessHistFloor) * loudnessHistPerLU)), counts: []int32{n}, } } func TestMergeHistograms_SumsAcrossDifferentRanges(t *testing.T) { a := blockHistogram{start: 500, counts: []int32{1, 0, 2}} // bins 500..502 b := blockHistogram{start: 501, counts: []int32{4, 0, 0, 5}} // bins 501..504 got := mergeHistograms([]blockHistogram{a, b}) want := blockHistogram{start: 500, counts: []int32{1, 4, 2, 0, 5}} if got.start != want.start || len(got.counts) != len(want.counts) { t.Fatalf("merged = %+v, want %+v", got, want) } for i := range want.counts { if got.counts[i] != want.counts[i] { t.Fatalf("merged = %+v, want %+v", got, want) } } // Bins past the range come only from a corrupt row; they are dropped, not // allowed to index out of the array. bad := blockHistogram{start: loudnessHistBins - 1, counts: []int32{1, 9}} if got := mergeHistograms([]blockHistogram{bad}); len(got.counts) != 1 || got.counts[0] != 1 { t.Errorf("out-of-range bin not dropped: %+v", got) } if !mergeHistograms(nil).empty() { t.Errorf("merging nothing gave a non-empty histogram") } } func input(settled bool, peak *float32, h blockHistogram) dbq.ListAlbumLoudnessInputsRow { row := dbq.ListAlbumLoudnessInputsRow{Settled: settled, TruePeakDbtp: peak} if !h.empty() { start := h.start row.BlockHistStart = &start row.BlockHist = h.counts } return row } func TestComputeAlbumLoudness(t *testing.T) { f := func(v float32) *float32 { return &v } // Album loudness gates over the whole album. A near-silent hidden track is // dropped by the relative gate, where averaging the tracks' values would // have let it pull the album 15 LU quieter. a := computeAlbumLoudness([]dbq.ListAlbumLoudnessInputsRow{ input(true, f(-1.0), histAt(-10, 1800)), input(true, f(-0.2), histAt(-10, 2400)), input(true, f(-30), histAt(-40, 600)), }) if a.integratedLUFS == nil || math.Abs(float64(*a.integratedLUFS)-(-10)) > 0.01 { t.Errorf("album loudness = %v, want -10 (the hidden track gated out)", a.integratedLUFS) } if a.truePeakDBTP == nil || *a.truePeakDBTP != -0.2 { t.Errorf("album peak = %v, want the loudest track's -0.2", a.truePeakDBTP) } if a.total != 3 || a.settled != 3 { t.Errorf("total/settled = %d/%d, want 3/3", a.total, a.settled) } // Energy, not an average of LUFS: two equally long tracks at -8 and -14 // make an album nearer the louder one than the midpoint (-11): -10.037. a = computeAlbumLoudness([]dbq.ListAlbumLoudnessInputsRow{ input(true, nil, histAt(-8, 1000)), input(true, nil, histAt(-14, 1000)), }) if a.integratedLUFS == nil || math.Abs(float64(*a.integratedLUFS)-(-10.037)) > 0.01 { t.Errorf("album loudness = %v, want -10.037", a.integratedLUFS) } // One track not yet measured: no album value until it is. a = computeAlbumLoudness([]dbq.ListAlbumLoudnessInputsRow{ input(true, f(-1), histAt(-10, 100)), input(false, nil, blockHistogram{}), }) if a.integratedLUFS != nil || a.settled != 1 || a.total != 2 { t.Errorf("partly measured album = %+v, want no loudness, 1 of 2 settled", a) } // Settled without blocks (silent, or unreadable): counted as settled and // leveled from the rest. a = computeAlbumLoudness([]dbq.ListAlbumLoudnessInputsRow{ input(true, f(-3), histAt(-12, 100)), input(true, nil, blockHistogram{}), }) if a.integratedLUFS == nil || math.Abs(float64(*a.integratedLUFS)-(-12)) > 0.01 { t.Errorf("album with a silent track = %v, want -12 from the other track", a.integratedLUFS) } // Every track silent: settled, but nothing to level by. a = computeAlbumLoudness([]dbq.ListAlbumLoudnessInputsRow{input(true, nil, blockHistogram{})}) if a.integratedLUFS != nil || a.settled != 1 { t.Errorf("all-silent album = %+v, want no loudness", a) } } // TestAlbumLoudness_Integration pins that the album pass computes from the // stored histograms, does nothing when nothing changed, and recomputes on // each kind of membership change. func TestAlbumLoudness_Integration(t *testing.T) { pool := newPool(t) ctx := context.Background() q := dbq.New(pool) dir := t.TempDir() first, album, artist := seedTrack(t, pool, filepath.Join(dir, "a.mp3")) addTrack := func(name string, albumID dbq.Album) dbq.Track { t.Helper() tr, err := q.UpsertTrack(ctx, dbq.UpsertTrackParams{ Title: name, AlbumID: albumID.ID, ArtistID: artist.ID, DurationMs: 1000, FilePath: filepath.Join(dir, name+".mp3"), FileSize: 100, FileFormat: "mp3", }) if err != nil { t.Fatalf("track %s: %v", name, err) } return tr } measure := func(tr dbq.Track, lufs float64, peak float32) { t.Helper() h := histAt(lufs, 100) l := float32(lufs) if err := q.UpsertTrackLoudness(ctx, dbq.UpsertTrackLoudnessParams{ TrackID: tr.ID, IntegratedLufs: &l, TruePeakDbtp: &peak, BlockHistStart: &h.start, BlockHist: h.counts, AnalysisVersion: loudnessVersion, }); err != nil { t.Fatalf("measure %s: %v", tr.Title, err) } } read := func() (lufs, peak *float32, total, settled int32) { t.Helper() if err := pool.QueryRow(ctx, "SELECT integrated_lufs, true_peak_dbtp, tracks_total, tracks_settled FROM album_loudness WHERE album_id = $1", album.ID).Scan(&lufs, &peak, &total, &settled); err != nil { t.Fatalf("read album loudness: %v", err) } return } w := NewLoudnessBackfillWorker(pool, slog.New(slog.NewTextHandler(io.Discard, nil)), nil) w.albumBatch = 1 // forces the keyset cursor across queries pass := func() AlbumLoudnessResult { t.Helper() res, err := w.albumPass(ctx) if err != nil { t.Fatalf("album pass: %v", err) } return res } albumChanges := func() int { t.Helper() var n int if err := pool.QueryRow(ctx, `SELECT count(*) FROM library_changes WHERE entity_type = 'album' AND entity_id = $1`, syncpkg.FormatUUID(album.ID)).Scan(&n); err != nil { t.Fatalf("count album changes: %v", err) } return n } second := addTrack("b", album) measure(first, -10, -1) // 1. One track unmeasured: the album is stored, waiting, without loudness. if res := pass(); res.Recomputed != 1 || res.Waiting != 1 { t.Fatalf("first pass = %+v, want 1 recomputed, waiting", res) } if lufs, _, total, settled := read(); lufs != nil || total != 2 || settled != 1 { t.Fatalf("waiting album = lufs %v, %d/%d settled; want nil, 1/2", lufs, settled, total) } // Still no album value: clients have nothing new to read (#4997). if n := albumChanges(); n != 0 { t.Fatalf("a waiting album logged %d sync changes, want 0", n) } // 2. Measuring the second track changes the digest; the album is leveled. measure(second, -10, -0.5) if res := pass(); res.Recomputed != 1 || res.Leveled != 1 { t.Fatalf("second pass = %+v, want 1 leveled", res) } lufs, peak, _, _ := read() if lufs == nil || math.Abs(float64(*lufs)-(-10)) > 0.01 || peak == nil || *peak != -0.5 { t.Fatalf("leveled album = lufs %v peak %v, want -10 and -0.5", lufs, peak) } // Leveled: clients are told, once. if n := albumChanges(); n != 1 { t.Fatalf("leveling the album logged %d sync changes, want 1", n) } // 3. Nothing changed: nothing recomputed, nothing logged. if res := pass(); res.Recomputed != 0 { t.Fatalf("idle pass = %+v, want nothing recomputed", res) } if n := albumChanges(); n != 1 { t.Fatalf("an idle pass logged album changes (now %d), want still 1", n) } // 4. A track joins (here, retagged onto this album): recomputed. other, err := q.UpsertAlbum(ctx, dbq.UpsertAlbumParams{Title: "Other", SortTitle: "Other", ArtistID: artist.ID}) if err != nil { t.Fatalf("other album: %v", err) } loud := addTrack("loud", other) measure(loud, -4, 0.3) pass() if _, err := pool.Exec(ctx, "UPDATE tracks SET album_id = $1 WHERE id = $2", album.ID, loud.ID); err != nil { t.Fatalf("move track: %v", err) } if res := pass(); res.Recomputed < 1 { t.Fatalf("pass after a track joined = %+v, want a recompute", res) } // Two tracks at -10 and one at -4, equally long: -7.003 by energy. if lufs, peak, total, _ := read(); total != 3 || lufs == nil || math.Abs(float64(*lufs)-(-7.003)) > 0.01 || peak == nil || *peak != 0.3 { t.Fatalf("album after a loud track joined = lufs %v peak %v total %d", lufs, peak, total) } // 5. A track's file goes missing: recomputed without it. if _, err := pool.Exec(ctx, "UPDATE tracks SET missing_since = now() WHERE id = $1", loud.ID); err != nil { t.Fatalf("mark missing: %v", err) } pass() if lufs, peak, total, _ := read(); total != 2 || lufs == nil || math.Abs(float64(*lufs)-(-10)) > 0.01 || peak == nil || *peak != -0.5 { t.Fatalf("album after the loud track went missing = lufs %v peak %v total %d", lufs, peak, total) } // 6. A track is deleted (as a duplicate merge does): recomputed. if _, err := pool.Exec(ctx, "DELETE FROM tracks WHERE id = $1", second.ID); err != nil { t.Fatalf("delete track: %v", err) } pass() if _, _, total, _ := read(); total != 1 { t.Fatalf("album after a delete has %d tracks, want 1", total) } // 7. Every remaining track missing: the album row is dropped, not left // describing tracks that are gone. if _, err := pool.Exec(ctx, "UPDATE tracks SET missing_since = now() WHERE id = $1", first.ID); err != nil { t.Fatalf("mark missing: %v", err) } before := albumChanges() if res := pass(); res.Orphans != 1 { t.Fatalf("pass with every track missing = %+v, want 1 orphan dropped", res) } // Dropping the row takes the album gain away, so clients are told. if n := albumChanges(); n != before+1 { t.Fatalf("dropping the orphan logged %d album changes, want 1", n-before) } var n int if err := pool.QueryRow(ctx, "SELECT count(*) FROM album_loudness WHERE album_id = $1", album.ID).Scan(&n); err != nil { t.Fatalf("count: %v", err) } if n != 0 { t.Fatalf("album loudness row survived every track going missing") } }