release / govulncheck (push) Successful in 27s
release / web (push) Successful in 1m27s
release / go (push) Successful in 1m47s
release / integration (push) Successful in 4m51s
release / android (push) Successful in 6m23s
release / Build signed APK (releases and dev) (push) Successful in 6m25s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m30s
release / Verify release artifacts (tag releases only) (push) Skipped
Album-mode normalization plays a whole album at one gain. That gain comes from album_loudness (migration 0066): BS.1770's gated loudness over every block on the album, computed by summing the tracks' stored histograms and gating the sum. No audio is decoded again. Album true peak is the loudest track's. - Recomputed by the loudness worker each tick, after the track pass and whether or not analysis is switched on. ListAlbumsNeedingLoudness lists albums whose md5 over (present track id, measurement version and time) no longer matches the stored digest. One comparison covers every way membership changes (scan retag, duplicate merge, delete, missing and restored) without hooking each. - No album value until every present track has a settled measurement, so an album's gain doesn't shift mid-listen as the rest is measured. Silent and unreadable tracks count as settled. - Rows for albums with no present track left are dropped. - The parser and the merge share trimBins. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
246 lines
9.0 KiB
Go
246 lines
9.0 KiB
Go
package library
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"log/slog"
|
|
"math"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
|
)
|
|
|
|
// 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
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 3. Nothing changed: nothing recomputed.
|
|
if res := pass(); res.Recomputed != 0 {
|
|
t.Fatalf("idle pass = %+v, want nothing recomputed", res)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
if res := pass(); res.Orphans != 1 {
|
|
t.Fatalf("pass with every track missing = %+v, want 1 orphan dropped", res)
|
|
}
|
|
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")
|
|
}
|
|
}
|