Files
minstrel/internal/library/album_loudness_test.go
T
bvandeusenandClaude Opus 5.5 e3aa8629d3
release / web (push) Successful in 1m44s
release / go (push) Successful in 2m12s
release / govulncheck (push) Successful in 40s
release / android (push) Successful in 5m28s
release / Build signed APK (releases and dev) (push) Successful in 4m42s
release / integration (push) Successful in 15m33s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m21s
release / Verify release artifacts (tag releases only) (push) Skipped
feat(api): deliver loudness gains to every client (M464 #4997)
ReplayGain 2.0 values (gain to -18 LUFS, linear peak) derived from the
stored track and album loudness:

- Web: GET /api/tracks/replay-gain?ids=... (up to 200), a lookup the
  player calls for its queue, rather than a field on every TrackRef
  surface.
- Android: track_gain/track_peak and album_gain/album_peak on the sync
  views, so cached tracks level offline. Storing a measurement logs a
  track change, and an album's values moving logs an album change, both
  before the write (#2704), so caches pick the gains up.
- OpenSubsonic: replayGain on every song (album, getSong, search3,
  starred), as a JSON object and an XML element.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:54:05 -04:00

276 lines
10 KiB
Go

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