diff --git a/internal/db/dbq/loudness.sql.go b/internal/db/dbq/loudness.sql.go index 1c05ba61..e4aa5f71 100644 --- a/internal/db/dbq/loudness.sql.go +++ b/internal/db/dbq/loudness.sql.go @@ -11,6 +11,24 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +const deleteOrphanAlbumLoudness = `-- name: DeleteOrphanAlbumLoudness :execrows +DELETE FROM album_loudness a + WHERE NOT EXISTS ( + SELECT 1 FROM tracks t WHERE t.album_id = a.album_id AND t.missing_since IS NULL + ) +` + +// Album rows whose album has no present track left (every file missing). The +// album row itself survives a missing file, so its loudness would otherwise +// stay behind describing tracks that are gone; it is recomputed if they return. +func (q *Queries) DeleteOrphanAlbumLoudness(ctx context.Context) (int64, error) { + result, err := q.db.Exec(ctx, deleteOrphanAlbumLoudness) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + const deleteTrackLoudness = `-- name: DeleteTrackLoudness :exec DELETE FROM track_loudness WHERE track_id = $1 ` @@ -83,6 +101,120 @@ func (q *Queries) GetLoudnessSettings(ctx context.Context) (LoudnessSetting, err return i, err } +const listAlbumLoudnessInputs = `-- name: ListAlbumLoudnessInputs :many +SELECT t.id, + (l.track_id IS NOT NULL)::boolean AS settled, + l.true_peak_dbtp, + l.block_hist_start, + l.block_hist + FROM tracks t + LEFT JOIN track_loudness l + ON l.track_id = t.id AND l.analysis_version >= $1 + WHERE t.album_id = $2 + AND t.missing_since IS NULL +` + +type ListAlbumLoudnessInputsParams struct { + CurrentVersion int16 + AlbumID pgtype.UUID +} + +type ListAlbumLoudnessInputsRow struct { + ID pgtype.UUID + Settled bool + TruePeakDbtp *float32 + BlockHistStart *int16 + BlockHist []int32 +} + +// Every present track on one album with its current measurement, if any. +// settled is false for a track not yet measured at the current version. +func (q *Queries) ListAlbumLoudnessInputs(ctx context.Context, arg ListAlbumLoudnessInputsParams) ([]ListAlbumLoudnessInputsRow, error) { + rows, err := q.db.Query(ctx, listAlbumLoudnessInputs, arg.CurrentVersion, arg.AlbumID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListAlbumLoudnessInputsRow + for rows.Next() { + var i ListAlbumLoudnessInputsRow + if err := rows.Scan( + &i.ID, + &i.Settled, + &i.TruePeakDbtp, + &i.BlockHistStart, + &i.BlockHist, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listAlbumsNeedingLoudness = `-- name: ListAlbumsNeedingLoudness :many +WITH present AS ( + SELECT t.album_id, + md5(string_agg( + t.id::text || ':' || coalesce( + l.analysis_version::text || '@' || l.analyzed_at::text, '-'), + ',' ORDER BY t.id)) AS digest + FROM tracks t + LEFT JOIN track_loudness l + ON l.track_id = t.id AND l.analysis_version >= $3::smallint + WHERE t.missing_since IS NULL + GROUP BY t.album_id +) +SELECT c.album_id, c.digest::text AS digest + FROM present c + LEFT JOIN album_loudness a ON a.album_id = c.album_id + WHERE (a.album_id IS NULL OR a.inputs_digest <> c.digest) + -- Casts: sqlc cannot infer a parameter's type through a CTE alias. + AND c.album_id > $1::uuid + ORDER BY c.album_id + LIMIT $2::integer +` + +type ListAlbumsNeedingLoudnessParams struct { + AfterID pgtype.UUID + BatchLimit int32 + CurrentVersion int16 +} + +type ListAlbumsNeedingLoudnessRow struct { + AlbumID pgtype.UUID + Digest string +} + +// The album pass's work queue (#4996): albums whose present tracks or their +// measurements have changed since album loudness was last computed, or that +// never had it. The digest is over every present track's id and the +// measurement it holds at the current version ('-' for none), so a track +// joining, leaving or being re-measured changes it. Keyset-paged on album id +// so a pass ends even if storing one album keeps failing. +func (q *Queries) ListAlbumsNeedingLoudness(ctx context.Context, arg ListAlbumsNeedingLoudnessParams) ([]ListAlbumsNeedingLoudnessRow, error) { + rows, err := q.db.Query(ctx, listAlbumsNeedingLoudness, arg.AfterID, arg.BatchLimit, arg.CurrentVersion) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListAlbumsNeedingLoudnessRow + for rows.Next() { + var i ListAlbumsNeedingLoudnessRow + if err := rows.Scan(&i.AlbumID, &i.Digest); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listTracksNeedingLoudness = `-- name: ListTracksNeedingLoudness :many SELECT t.id, t.file_path, t.duration_ms FROM tracks t @@ -159,6 +291,43 @@ func (q *Queries) UpdateLoudnessSettings(ctx context.Context, arg UpdateLoudness return i, err } +const upsertAlbumLoudness = `-- name: UpsertAlbumLoudness :exec +INSERT INTO album_loudness ( + album_id, integrated_lufs, true_peak_dbtp, tracks_total, tracks_settled, inputs_digest +) VALUES ( + $1, $2, $3, + $4, $5, $6 +) +ON CONFLICT (album_id) DO UPDATE SET + integrated_lufs = EXCLUDED.integrated_lufs, + true_peak_dbtp = EXCLUDED.true_peak_dbtp, + tracks_total = EXCLUDED.tracks_total, + tracks_settled = EXCLUDED.tracks_settled, + inputs_digest = EXCLUDED.inputs_digest, + computed_at = now() +` + +type UpsertAlbumLoudnessParams struct { + AlbumID pgtype.UUID + IntegratedLufs *float32 + TruePeakDbtp *float32 + TracksTotal int32 + TracksSettled int32 + InputsDigest string +} + +func (q *Queries) UpsertAlbumLoudness(ctx context.Context, arg UpsertAlbumLoudnessParams) error { + _, err := q.db.Exec(ctx, upsertAlbumLoudness, + arg.AlbumID, + arg.IntegratedLufs, + arg.TruePeakDbtp, + arg.TracksTotal, + arg.TracksSettled, + arg.InputsDigest, + ) + return err +} + const upsertTrackLoudness = `-- name: UpsertTrackLoudness :exec INSERT INTO track_loudness ( track_id, integrated_lufs, true_peak_dbtp, loudness_range_lu, diff --git a/internal/db/dbq/models.go b/internal/db/dbq/models.go index 99b5cbf7..fd55ee9c 100644 --- a/internal/db/dbq/models.go +++ b/internal/db/dbq/models.go @@ -201,6 +201,16 @@ type Album struct { CoverArtSourcesVersion int32 } +type AlbumLoudness struct { + AlbumID pgtype.UUID + IntegratedLufs *float32 + TruePeakDbtp *float32 + TracksTotal int32 + TracksSettled int32 + InputsDigest string + ComputedAt pgtype.Timestamptz +} + type Artist struct { ID pgtype.UUID Name string diff --git a/internal/db/migrations/0066_album_loudness.down.sql b/internal/db/migrations/0066_album_loudness.down.sql new file mode 100644 index 00000000..7e0a1306 --- /dev/null +++ b/internal/db/migrations/0066_album_loudness.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS album_loudness; diff --git a/internal/db/migrations/0066_album_loudness.up.sql b/internal/db/migrations/0066_album_loudness.up.sql new file mode 100644 index 00000000..9243dcb9 --- /dev/null +++ b/internal/db/migrations/0066_album_loudness.up.sql @@ -0,0 +1,31 @@ +-- 0066_album_loudness.up.sql — album loudness for album-mode normalization +-- (Scribe milestone #464, #4996). +-- +-- An album's loudness is the gated loudness of every 400 ms block on the album, +-- not an average of its tracks' values: a quiet interlude and a loud single +-- should keep their difference when the album plays in order. It is computed +-- from the per-track block histograms in track_loudness (0065), summed, so no +-- audio is decoded again. +-- +-- A derived value, recomputed by the loudness worker whenever its inputs +-- change. inputs_digest is an md5 over the album's present tracks and the +-- measurement each holds; the worker recomputes every album whose stored digest +-- no longer matches. That one comparison covers every way membership changes +-- (a scan moving a track between albums, a duplicate merge, a delete, a file +-- going missing or coming back) without hooking each of them. +CREATE TABLE album_loudness ( + album_id uuid PRIMARY KEY REFERENCES albums (id) ON DELETE CASCADE, + -- NULL until every present track has a settled measurement: an album + -- leveled from half its tracks would jump when the rest arrived. Also NULL + -- for an album with no block above the gate. Clients fall back to track + -- gain while it is NULL. + integrated_lufs real, + -- The loudest true peak of any track on the album, so album gain is held + -- to the headroom of its loudest track. + true_peak_dbtp real, + tracks_total integer NOT NULL, + -- Tracks with a settled measurement (measured, silent or unreadable). + tracks_settled integer NOT NULL, + inputs_digest text NOT NULL, + computed_at timestamptz NOT NULL DEFAULT now() +); diff --git a/internal/db/queries/loudness.sql b/internal/db/queries/loudness.sql index 821d0ad5..94aefb65 100644 --- a/internal/db/queries/loudness.sql +++ b/internal/db/queries/loudness.sql @@ -75,3 +75,69 @@ UPDATE loudness_settings updated_at = now() WHERE id = true RETURNING *; + +-- name: ListAlbumsNeedingLoudness :many +-- The album pass's work queue (#4996): albums whose present tracks or their +-- measurements have changed since album loudness was last computed, or that +-- never had it. The digest is over every present track's id and the +-- measurement it holds at the current version ('-' for none), so a track +-- joining, leaving or being re-measured changes it. Keyset-paged on album id +-- so a pass ends even if storing one album keeps failing. +WITH present AS ( + SELECT t.album_id, + md5(string_agg( + t.id::text || ':' || coalesce( + l.analysis_version::text || '@' || l.analyzed_at::text, '-'), + ',' ORDER BY t.id)) AS digest + FROM tracks t + LEFT JOIN track_loudness l + ON l.track_id = t.id AND l.analysis_version >= sqlc.arg(current_version)::smallint + WHERE t.missing_since IS NULL + GROUP BY t.album_id +) +SELECT c.album_id, c.digest::text AS digest + FROM present c + LEFT JOIN album_loudness a ON a.album_id = c.album_id + WHERE (a.album_id IS NULL OR a.inputs_digest <> c.digest) + -- Casts: sqlc cannot infer a parameter's type through a CTE alias. + AND c.album_id > sqlc.arg(after_id)::uuid + ORDER BY c.album_id + LIMIT sqlc.arg(batch_limit)::integer; + +-- name: ListAlbumLoudnessInputs :many +-- Every present track on one album with its current measurement, if any. +-- settled is false for a track not yet measured at the current version. +SELECT t.id, + (l.track_id IS NOT NULL)::boolean AS settled, + l.true_peak_dbtp, + l.block_hist_start, + l.block_hist + FROM tracks t + LEFT JOIN track_loudness l + ON l.track_id = t.id AND l.analysis_version >= sqlc.arg(current_version) + WHERE t.album_id = sqlc.arg(album_id) + AND t.missing_since IS NULL; + +-- name: UpsertAlbumLoudness :exec +INSERT INTO album_loudness ( + album_id, integrated_lufs, true_peak_dbtp, tracks_total, tracks_settled, inputs_digest +) VALUES ( + sqlc.arg(album_id), sqlc.narg(integrated_lufs), sqlc.narg(true_peak_dbtp), + sqlc.arg(tracks_total), sqlc.arg(tracks_settled), sqlc.arg(inputs_digest) +) +ON CONFLICT (album_id) DO UPDATE SET + integrated_lufs = EXCLUDED.integrated_lufs, + true_peak_dbtp = EXCLUDED.true_peak_dbtp, + tracks_total = EXCLUDED.tracks_total, + tracks_settled = EXCLUDED.tracks_settled, + inputs_digest = EXCLUDED.inputs_digest, + computed_at = now(); + +-- name: DeleteOrphanAlbumLoudness :execrows +-- Album rows whose album has no present track left (every file missing). The +-- album row itself survives a missing file, so its loudness would otherwise +-- stay behind describing tracks that are gone; it is recomputed if they return. +DELETE FROM album_loudness a + WHERE NOT EXISTS ( + SELECT 1 FROM tracks t WHERE t.album_id = a.album_id AND t.missing_since IS NULL + ); diff --git a/internal/dbtest/reset.go b/internal/dbtest/reset.go index 8d1558dc..24507d92 100644 --- a/internal/dbtest/reset.go +++ b/internal/dbtest/reset.go @@ -92,6 +92,7 @@ var dataTables = []string{ "duplicate_sweeps", "track_fingerprints", // M400 "track_loudness", // M464 + "album_loudness", // M464 "tracks", "albums", "artists", diff --git a/internal/library/album_loudness.go b/internal/library/album_loudness.go new file mode 100644 index 00000000..19ee5df4 --- /dev/null +++ b/internal/library/album_loudness.go @@ -0,0 +1,162 @@ +package library + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5/pgtype" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +// Album loudness (M464 #4996). +// +// Album-mode normalization plays a whole album at one gain, so the quiet +// interlude stays quieter than the single it sits between. That gain comes +// from the album's loudness: BS.1770's gated loudness over every block on the +// album, which is what summing the tracks' block histograms and gating the sum +// computes. An average of the tracks' values is not the same thing: gating +// over the whole album drops a near-silent hidden track, where averaging +// would let it drag the album quieter. +// +// The values are derived, so they are recomputed rather than maintained: each +// worker tick lists the albums whose inputs digest has moved (see +// ListAlbumsNeedingLoudness) and recomputes those from the stored histograms. + +// albumLoudnessBatch is how many albums one query hands the album pass. +// Recomputing an album is a few small reads and arithmetic, no decode. +const albumLoudnessBatch = 200 + +// albumLoudness is one album's computed values. +type albumLoudness struct { + // integratedLUFS is nil until every track is settled, or when no block on + // the album passed the gate. + integratedLUFS *float32 + truePeakDBTP *float32 + total, settled int32 +} + +// computeAlbumLoudness sums the present tracks' histograms and gates the sum. +func computeAlbumLoudness(inputs []dbq.ListAlbumLoudnessInputsRow) albumLoudness { + a := albumLoudness{total: int32(len(inputs))} + hists := make([]blockHistogram, 0, len(inputs)) + for _, in := range inputs { + if !in.Settled { + continue + } + a.settled++ + if in.TruePeakDbtp != nil && (a.truePeakDBTP == nil || *in.TruePeakDbtp > *a.truePeakDBTP) { + peak := *in.TruePeakDbtp + a.truePeakDBTP = &peak + } + if in.BlockHistStart != nil && len(in.BlockHist) > 0 { + hists = append(hists, blockHistogram{start: *in.BlockHistStart, counts: in.BlockHist}) + } + } + // Leveling from part of an album would change its gain as the rest is + // measured, audibly, mid-listen. Wait for all of it. + if a.total == 0 || a.settled < a.total { + return a + } + if lufs, ok := mergeHistograms(hists).gatedLoudness(); ok { + v := float32(lufs) + a.integratedLUFS = &v + } + return a +} + +// mergeHistograms sums histograms that may cover different bin ranges into +// one, trimmed to the occupied range. A histogram reaching past the bin range +// (only a corrupt row could) is clipped rather than trusted. +func mergeHistograms(hs []blockHistogram) blockHistogram { + var bins [loudnessHistBins]int32 + for _, h := range hs { + for i, c := range h.counts { + if b := int(h.start) + i; b >= 0 && b < loudnessHistBins { + bins[b] += c + } + } + } + return trimBins(&bins) +} + +// AlbumLoudnessResult tallies one album pass. +type AlbumLoudnessResult struct { + Recomputed int + Leveled int // stored with an album loudness + Waiting int // stored without one: a track is not yet measured + Failed int + Orphans int64 // rows dropped because the album has no present track +} + +// albumPass recomputes every album whose inputs changed, keyset-paged on album +// id so a pass ends even when one album keeps failing to store. +// +// The digest stored is the one the list query computed. If a track changes +// between that query and the read of its inputs, the stored digest is already +// stale and the next pass recomputes the album again, so the values always +// converge on the inputs. +func (w *LoudnessBackfillWorker) albumPass(ctx context.Context) (AlbumLoudnessResult, error) { + q := dbq.New(w.pool) + var res AlbumLoudnessResult + after := pgtype.UUID{Valid: true} + for { + if err := ctx.Err(); err != nil { + return res, err + } + rows, err := q.ListAlbumsNeedingLoudness(ctx, dbq.ListAlbumsNeedingLoudnessParams{ + CurrentVersion: loudnessVersion, + AfterID: after, + BatchLimit: w.albumBatch, + }) + if err != nil { + return res, fmt.Errorf("list albums needing loudness: %w", err) + } + if len(rows) == 0 { + break + } + for _, row := range rows { + res.Recomputed++ + if err := storeAlbumLoudness(ctx, q, row.AlbumID, row.Digest, &res); err != nil { + res.Failed++ + w.logger.Warn("album loudness: recompute failed", "album_id", row.AlbumID, "err", err) + } + } + after = rows[len(rows)-1].AlbumID + } + n, err := q.DeleteOrphanAlbumLoudness(ctx) + if err != nil { + return res, fmt.Errorf("drop orphan album loudness: %w", err) + } + res.Orphans = n + return res, nil +} + +func storeAlbumLoudness( + ctx context.Context, q *dbq.Queries, albumID pgtype.UUID, digest string, res *AlbumLoudnessResult, +) error { + inputs, err := q.ListAlbumLoudnessInputs(ctx, dbq.ListAlbumLoudnessInputsParams{ + CurrentVersion: loudnessVersion, + AlbumID: albumID, + }) + if err != nil { + return fmt.Errorf("read inputs: %w", err) + } + a := computeAlbumLoudness(inputs) + if err := q.UpsertAlbumLoudness(ctx, dbq.UpsertAlbumLoudnessParams{ + AlbumID: albumID, + IntegratedLufs: a.integratedLUFS, + TruePeakDbtp: a.truePeakDBTP, + TracksTotal: a.total, + TracksSettled: a.settled, + InputsDigest: digest, + }); err != nil { + return fmt.Errorf("store: %w", err) + } + if a.integratedLUFS != nil { + res.Leveled++ + } else { + res.Waiting++ + } + return nil +} diff --git a/internal/library/album_loudness_test.go b/internal/library/album_loudness_test.go new file mode 100644 index 00000000..50badf13 --- /dev/null +++ b/internal/library/album_loudness_test.go @@ -0,0 +1,245 @@ +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") + } +} diff --git a/internal/library/loudness.go b/internal/library/loudness.go index ffea3c9f..2fd50032 100644 --- a/internal/library/loudness.go +++ b/internal/library/loudness.go @@ -220,7 +220,6 @@ var ( // histogram, and the closing summary. type ebur128Parser struct { bins [loudnessHistBins]int32 - blocks int inSummary bool summary map[string]string lastLines []string @@ -273,7 +272,6 @@ func (p *ebur128Parser) line(raw string) { } bin := int(math.Round((v - loudnessHistFloor) * loudnessHistPerLU)) p.bins[min(bin, loudnessHistBins-1)]++ - p.blocks++ } func (p *ebur128Parser) tail() string { @@ -281,18 +279,24 @@ func (p *ebur128Parser) tail() string { } func (p *ebur128Parser) histogram() blockHistogram { - if p.blocks == 0 { - return blockHistogram{} - } + return trimBins(&p.bins) +} + +// trimBins turns a full-range bin array into a histogram trimmed to its +// occupied bins; an empty array gives an empty histogram. +func trimBins(bins *[loudnessHistBins]int32) blockHistogram { first, last := 0, loudnessHistBins-1 - for p.bins[first] == 0 { + for first <= last && bins[first] == 0 { first++ } - for p.bins[last] == 0 { + if first > last { + return blockHistogram{} + } + for bins[last] == 0 { last-- } counts := make([]int32, last-first+1) - copy(counts, p.bins[first:last+1]) + copy(counts, bins[first:last+1]) return blockHistogram{start: int16(first), counts: counts} } diff --git a/internal/library/loudness_backfill.go b/internal/library/loudness_backfill.go index 8dc84dd0..9bf05025 100644 --- a/internal/library/loudness_backfill.go +++ b/internal/library/loudness_backfill.go @@ -64,11 +64,12 @@ func (r *BackfillLoudnessResult) add(o loudnessOutcome) { // LoudnessBackfillWorker measures every track that has no current measurement. type LoudnessBackfillWorker struct { - pool *pgxpool.Pool - logger *slog.Logger - settings *LoudnessSettingsService - tick time.Duration - batch int32 + pool *pgxpool.Pool + logger *slog.Logger + settings *LoudnessSettingsService + tick time.Duration + batch int32 + albumBatch int32 // analyze is a field so an integration test pins which tracks a pass // touches, not what ffmpeg prints. analyze func(ctx context.Context, path string, durationMs int32) loudnessResult @@ -80,12 +81,13 @@ func NewLoudnessBackfillWorker( pool *pgxpool.Pool, logger *slog.Logger, settings *LoudnessSettingsService, ) *LoudnessBackfillWorker { return &LoudnessBackfillWorker{ - pool: pool, - logger: logger, - settings: settings, - tick: loudnessBackfillTick, - batch: loudnessBackfillBatch, - analyze: computeLoudness, + pool: pool, + logger: logger, + settings: settings, + tick: loudnessBackfillTick, + batch: loudnessBackfillBatch, + albumBatch: albumLoudnessBatch, + analyze: computeLoudness, } } @@ -121,6 +123,18 @@ func (w *LoudnessBackfillWorker) runOnce(ctx context.Context) { "processed", res.Processed, "measured", res.Measured, "silent", res.Silent, "unreadable", res.Unreadable, "inconclusive", res.Inconclusive) } + // Album loudness follows the tracks (#4996). It runs even with analysis + // switched off: it decodes nothing, and membership still changes as the + // library does. + albums, err := w.albumPass(ctx) + if err != nil && ctx.Err() == nil { + w.logger.Warn("album loudness: pass failed", "err", err, "recomputed", albums.Recomputed) + } + if albums.Recomputed > 0 || albums.Orphans > 0 { + w.logger.Info("album loudness: pass complete", + "recomputed", albums.Recomputed, "leveled", albums.Leveled, + "waiting", albums.Waiting, "failed", albums.Failed, "orphans", albums.Orphans) + } } // pass walks every track needing a measurement once, keyset-paged on id. The