feat(library): album loudness from the tracks' summed block histograms (M464 #4996)
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>
This commit is contained in:
2026-10-06 13:27:02 -04:00
co-authored by Claude Opus 5.5
parent aee4b50bd4
commit c2f81bf8df
10 changed files with 722 additions and 19 deletions
+169
View File
@@ -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,