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
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>
276 lines
10 KiB
Go
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")
|
|
}
|
|
}
|