feat(library): measure every track's loudness in the background (M464 #4995)
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
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
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>
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
package library
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
|
||||
// Loudness backfill (M464 #4995).
|
||||
//
|
||||
// Every track is measured here, the new ones included: the scan only deletes a
|
||||
// changed file's measurement (see scanFile), and this worker measures it again.
|
||||
// Measuring inline in the scan was the first plan and was dropped, because the
|
||||
// analysis decodes the whole file. Added to the scan, a large import would run
|
||||
// several times longer and could pass StuckScanThreshold (1h), at which point
|
||||
// the run is reaped and a second scan started beside it. The fingerprint
|
||||
// backfill is a worker of its own for the same reason.
|
||||
//
|
||||
// Until a track is measured it plays with no gain adjustment, which is how
|
||||
// every track played before normalization existed.
|
||||
|
||||
// loudnessBackfillTick is how often the worker looks for work. Shorter than
|
||||
// the fingerprint backfill's hour, because a new track plays unleveled until it
|
||||
// is measured; once the library has caught up, a tick is one indexed query.
|
||||
const loudnessBackfillTick = 10 * time.Minute
|
||||
|
||||
// loudnessBackfillBatch is how many tracks one query hands the worker.
|
||||
const loudnessBackfillBatch = 50
|
||||
|
||||
// loudnessBackfillConcurrency is the shipped value of the concurrency setting.
|
||||
// Low for the reason fingerprinting's is: each analysis is a full decode,
|
||||
// competing with playback transcoding and streaming.
|
||||
const loudnessBackfillConcurrency = 2
|
||||
|
||||
// BackfillLoudnessResult tallies one pass.
|
||||
type BackfillLoudnessResult struct {
|
||||
Processed int
|
||||
Measured int
|
||||
Silent int // read fine, no block above the gate (settled)
|
||||
Unreadable int // ffmpeg could not decode the file (settled)
|
||||
Inconclusive int // nothing stored; tried again on a later pass
|
||||
}
|
||||
|
||||
func (r *BackfillLoudnessResult) add(o loudnessOutcome) {
|
||||
r.Processed++
|
||||
switch o {
|
||||
case loudnessMeasured:
|
||||
r.Measured++
|
||||
case loudnessSilent:
|
||||
r.Silent++
|
||||
case loudnessUnreadable:
|
||||
r.Unreadable++
|
||||
default:
|
||||
r.Inconclusive++
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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
|
||||
}
|
||||
|
||||
// NewLoudnessBackfillWorker builds a worker with the production cadence.
|
||||
// settings is shared with the admin API; nil runs on defaults.
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
// Run blocks until ctx is cancelled: one pass at start, then one per tick.
|
||||
func (w *LoudnessBackfillWorker) Run(ctx context.Context) {
|
||||
w.runOnce(ctx)
|
||||
t := time.NewTicker(w.tick)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
w.runOnce(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runOnce contains a pass so that nothing it does (an error, a panic) can stop
|
||||
// the next tick from firing (rule 157).
|
||||
func (w *LoudnessBackfillWorker) runOnce(ctx context.Context) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
w.logger.Error("loudness backfill: pass panicked", "panic", r)
|
||||
}
|
||||
}()
|
||||
res, err := w.pass(ctx)
|
||||
if err != nil && ctx.Err() == nil {
|
||||
w.logger.Warn("loudness backfill: pass failed", "err", err, "processed", res.Processed)
|
||||
}
|
||||
if res.Processed > 0 {
|
||||
w.logger.Info("loudness backfill: pass complete",
|
||||
"processed", res.Processed, "measured", res.Measured, "silent", res.Silent,
|
||||
"unreadable", res.Unreadable, "inconclusive", res.Inconclusive)
|
||||
}
|
||||
}
|
||||
|
||||
// pass walks every track needing a measurement once, keyset-paged on id. The
|
||||
// cursor is what lets a pass end: an inconclusive attempt writes no row, so a
|
||||
// file that keeps timing out would otherwise be listed again immediately.
|
||||
// Settings are read before every batch, so switching analysis off ends the
|
||||
// pass and a new concurrency applies to the next batch.
|
||||
func (w *LoudnessBackfillWorker) pass(ctx context.Context) (BackfillLoudnessResult, error) {
|
||||
q := dbq.New(w.pool)
|
||||
var (
|
||||
res BackfillLoudnessResult
|
||||
mu sync.Mutex
|
||||
)
|
||||
// The all-zero uuid sorts before every real id. Valid must be true: a NULL
|
||||
// cursor would make "id > NULL" match nothing and every pass a no-op.
|
||||
after := pgtype.UUID{Valid: true}
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return res, err
|
||||
}
|
||||
cfg := w.settings.Get()
|
||||
if !cfg.Enabled {
|
||||
return res, nil
|
||||
}
|
||||
rows, err := q.ListTracksNeedingLoudness(ctx, dbq.ListTracksNeedingLoudnessParams{
|
||||
CurrentVersion: loudnessVersion,
|
||||
AfterID: after,
|
||||
BatchLimit: w.batch,
|
||||
})
|
||||
if err != nil {
|
||||
return res, fmt.Errorf("list tracks needing loudness: %w", err)
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return res, nil
|
||||
}
|
||||
|
||||
sem := make(chan struct{}, max(1, int(cfg.BackfillConcurrency)))
|
||||
var wg sync.WaitGroup
|
||||
for _, row := range rows {
|
||||
if ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
sem <- struct{}{}
|
||||
wg.Add(1)
|
||||
go func(row dbq.ListTracksNeedingLoudnessRow) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
w.logger.Error("loudness backfill: track panicked", "path", row.FilePath, "panic", r)
|
||||
}
|
||||
}()
|
||||
outcome := storeLoudness(ctx, q, w.logger, row.ID, row.FilePath,
|
||||
w.analyzeFile(ctx, row.FilePath, row.DurationMs))
|
||||
mu.Lock()
|
||||
res.add(outcome)
|
||||
mu.Unlock()
|
||||
}(row)
|
||||
}
|
||||
wg.Wait()
|
||||
after = rows[len(rows)-1].ID
|
||||
}
|
||||
}
|
||||
|
||||
func (w *LoudnessBackfillWorker) analyzeFile(ctx context.Context, path string, durationMs int32) loudnessResult {
|
||||
if w.analyze == nil {
|
||||
return computeLoudness(ctx, path, durationMs)
|
||||
}
|
||||
return w.analyze(ctx, path, durationMs)
|
||||
}
|
||||
|
||||
// LoudnessCoverage reports how much of the library carries a current
|
||||
// measurement, for the admin gauge. It lives beside the backfill so the
|
||||
// version it counts against is the one the backfill writes.
|
||||
func LoudnessCoverage(ctx context.Context, pool *pgxpool.Pool) (dbq.GetLoudnessCoverageRow, error) {
|
||||
return dbq.New(pool).GetLoudnessCoverage(ctx, loudnessVersion)
|
||||
}
|
||||
Reference in New Issue
Block a user