Files
minstrel/internal/db/dbq/loudness.sql.go
T
bvandeusenandClaude Opus 5.5 f3196b3443
release / go (push) Failing after 1m18s
release / web (push) Successful in 1m27s
release / govulncheck (push) Successful in 35s
release / integration (push) Failing after 4m13s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / android (push) Canceled after 5m47s
release / Build signed APK (releases and dev) (push) Canceled after 4m21s
feat(library): measure every track's loudness in the background (M464 #4995)
The first step of loudness normalization: the server measures each track
with ffmpeg's EBU R128 filter (true peak, mono as dual mono) and stores the
integrated loudness, true peak and loudness range in track_loudness
(migration 0065).

It also keeps a histogram of the 400 ms gating blocks at 0.1 LU, so album
loudness can be computed exactly later with no second decode (#4996). The
histogram reproduces ffmpeg's own figure (-10.68 against -10.7 on the
captured fixture), and the analyzer logs a warning if the two ever drift.

- A background worker, cloned from the fingerprint backfill, measures every
  track, new ones included. Measuring inline in the scan was dropped: the
  analysis decodes the whole file, and a large import could pass the scan's
  one-hour stuck threshold. The scan only deletes a changed file's
  measurement; the worker ticks every 10 minutes.
- Timeouts, the cancel/missing-binary split and settled verdicts follow the
  fingerprint runner. Silence and undecodable files are stored as verdicts;
  stalls are retried. The deadline scales with track length.
- loudness_settings (enabled, files at once) and an admin card with the
  coverage gauge, under GET/PUT /api/admin/library/loudness-settings and
  GET /api/admin/library/loudness.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:01:01 -04:00

209 lines
5.9 KiB
Go

// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: loudness.sql
package dbq
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const deleteTrackLoudness = `-- name: DeleteTrackLoudness :exec
DELETE FROM track_loudness WHERE track_id = $1
`
// The scan saw new bytes at this path. The stored measurement describes the old
// ones, so it goes, and the backfill measures the file again.
func (q *Queries) DeleteTrackLoudness(ctx context.Context, trackID pgtype.UUID) error {
_, err := q.db.Exec(ctx, deleteTrackLoudness, trackID)
return err
}
const getLoudnessCoverage = `-- name: GetLoudnessCoverage :one
SELECT count(*)::bigint AS total,
count(*) FILTER (
WHERE l.analysis_version >= $1
AND l.integrated_lufs IS NOT NULL
)::bigint AS measured,
count(*) FILTER (
WHERE l.analysis_version >= $1
AND l.integrated_lufs IS NULL AND NOT l.unreadable
)::bigint AS silent,
count(*) FILTER (
WHERE l.analysis_version >= $1
AND l.unreadable
)::bigint AS unreadable,
count(*) FILTER (
WHERE l.track_id IS NULL OR l.analysis_version < $1
)::bigint AS pending
FROM tracks t
LEFT JOIN track_loudness l ON l.track_id = t.id
WHERE t.missing_since IS NULL
`
type GetLoudnessCoverageRow struct {
Total int64
Measured int64
Silent int64
Unreadable int64
Pending int64
}
// The admin gauge. measured + silent + unreadable + pending = total. Missing
// tracks are excluded, or the gauge could never reach the end.
func (q *Queries) GetLoudnessCoverage(ctx context.Context, currentVersion int16) (GetLoudnessCoverageRow, error) {
row := q.db.QueryRow(ctx, getLoudnessCoverage, currentVersion)
var i GetLoudnessCoverageRow
err := row.Scan(
&i.Total,
&i.Measured,
&i.Silent,
&i.Unreadable,
&i.Pending,
)
return i, err
}
const getLoudnessSettings = `-- name: GetLoudnessSettings :one
SELECT id, enabled, backfill_concurrency, updated_at FROM loudness_settings WHERE id = true
`
func (q *Queries) GetLoudnessSettings(ctx context.Context) (LoudnessSetting, error) {
row := q.db.QueryRow(ctx, getLoudnessSettings)
var i LoudnessSetting
err := row.Scan(
&i.ID,
&i.Enabled,
&i.BackfillConcurrency,
&i.UpdatedAt,
)
return i, err
}
const listTracksNeedingLoudness = `-- name: ListTracksNeedingLoudness :many
SELECT t.id, t.file_path, t.duration_ms
FROM tracks t
LEFT JOIN track_loudness l ON l.track_id = t.id
WHERE t.missing_since IS NULL
AND (l.track_id IS NULL OR l.analysis_version < $1)
AND t.id > $2
ORDER BY t.id
LIMIT $3
`
type ListTracksNeedingLoudnessParams struct {
CurrentVersion int16
AfterID pgtype.UUID
BatchLimit int32
}
type ListTracksNeedingLoudnessRow struct {
ID pgtype.UUID
FilePath string
DurationMs int32
}
// The backfill's work queue: tracks with no measurement, or one taken by an
// older method. Keyset-paged on id so a pass visits each track at most once;
// an inconclusive attempt writes no row, and without the cursor a file that
// keeps timing out would be listed again straight away. Missing tracks are
// skipped: there is no file to read.
func (q *Queries) ListTracksNeedingLoudness(ctx context.Context, arg ListTracksNeedingLoudnessParams) ([]ListTracksNeedingLoudnessRow, error) {
rows, err := q.db.Query(ctx, listTracksNeedingLoudness, arg.CurrentVersion, arg.AfterID, arg.BatchLimit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListTracksNeedingLoudnessRow
for rows.Next() {
var i ListTracksNeedingLoudnessRow
if err := rows.Scan(&i.ID, &i.FilePath, &i.DurationMs); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const updateLoudnessSettings = `-- name: UpdateLoudnessSettings :one
UPDATE loudness_settings
SET enabled = $1,
backfill_concurrency = $2,
updated_at = now()
WHERE id = true
RETURNING id, enabled, backfill_concurrency, updated_at
`
type UpdateLoudnessSettingsParams struct {
Enabled bool
BackfillConcurrency int32
}
// Whole-row write from the admin card; migration 0065's CHECK is the backstop
// behind the service's own validation.
func (q *Queries) UpdateLoudnessSettings(ctx context.Context, arg UpdateLoudnessSettingsParams) (LoudnessSetting, error) {
row := q.db.QueryRow(ctx, updateLoudnessSettings, arg.Enabled, arg.BackfillConcurrency)
var i LoudnessSetting
err := row.Scan(
&i.ID,
&i.Enabled,
&i.BackfillConcurrency,
&i.UpdatedAt,
)
return i, err
}
const upsertTrackLoudness = `-- name: UpsertTrackLoudness :exec
INSERT INTO track_loudness (
track_id, integrated_lufs, true_peak_dbtp, loudness_range_lu,
block_hist_start, block_hist, unreadable, analysis_version
) VALUES (
$1, $2, $3,
$4, $5, $6,
$7, $8
)
ON CONFLICT (track_id) DO UPDATE SET
integrated_lufs = EXCLUDED.integrated_lufs,
true_peak_dbtp = EXCLUDED.true_peak_dbtp,
loudness_range_lu = EXCLUDED.loudness_range_lu,
block_hist_start = EXCLUDED.block_hist_start,
block_hist = EXCLUDED.block_hist,
unreadable = EXCLUDED.unreadable,
analysis_version = EXCLUDED.analysis_version,
analyzed_at = now()
`
type UpsertTrackLoudnessParams struct {
TrackID pgtype.UUID
IntegratedLufs *float32
TruePeakDbtp *float32
LoudnessRangeLu *float32
BlockHistStart *int16
BlockHist []int32
Unreadable bool
AnalysisVersion int16
}
// Written when the backfill measures a track (#4995). Replaces the row
// wholesale: a measurement of the old bytes has no standing once the file has
// changed.
func (q *Queries) UpsertTrackLoudness(ctx context.Context, arg UpsertTrackLoudnessParams) error {
_, err := q.db.Exec(ctx, upsertTrackLoudness,
arg.TrackID,
arg.IntegratedLufs,
arg.TruePeakDbtp,
arg.LoudnessRangeLu,
arg.BlockHistStart,
arg.BlockHist,
arg.Unreadable,
arg.AnalysisVersion,
)
return err
}