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
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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS album_loudness;
|
||||
@@ -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()
|
||||
);
|
||||
@@ -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
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user