Files
minstrel/internal/db/migrations/0065_track_loudness.up.sql
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

66 lines
3.5 KiB
SQL

-- 0065_track_loudness.up.sql — measured loudness per track, for loudness
-- normalization (Scribe milestone #464, #4995).
--
-- Measured with ffmpeg's EBU R128 filter rather than read from ReplayGain tags:
-- tags in the wild are written against four different reference levels, and
-- most files have none. internal/library/loudness.go says how it is measured.
--
-- A table of its own rather than columns on tracks, for the reason
-- track_fingerprints is (0058): tracks is read with SELECT * on the hot path,
-- and the block histogram is a few hundred integers only album loudness reads.
--
-- What a row means, which the backfill depends on:
-- no row never analyzed, or the file changed since
-- analysis_version < current measured by an older method; measure again
-- analysis_version = current settled until the file changes:
-- integrated_lufs NOT NULL measured
-- integrated_lufs NULL, unreadable false
-- read fine, but no 400 ms block was above
-- the -70 LUFS gate: silence, or too short
-- unreadable true ffmpeg could not decode the file
-- A failure that says nothing about the file (a timeout, a cancelled pass, a
-- missing ffmpeg) writes no row, so the backfill tries again.
CREATE TABLE track_loudness (
track_id uuid PRIMARY KEY REFERENCES tracks (id) ON DELETE CASCADE,
-- Gated integrated loudness (ITU-R BS.1770), mono measured as dual mono.
integrated_lufs real,
-- Highest inter-sample peak, from 4x oversampling, in dB relative to full
-- scale. NULL for digital silence, whose peak is -inf.
true_peak_dbtp real,
-- Loudness range (EBU Tech 3342): how much the loudness moves within the
-- track. Not used for gain; kept because it costs nothing here.
loudness_range_lu real,
-- How many 400 ms gating blocks fell in each 0.1 LU bin. Bin i holds blocks
-- measuring -70.0 + (block_hist_start + i) / 10 LUFS; the array is trimmed
-- to the first and last non-empty bins. Album loudness is the gated loudness
-- of every block on the album, so it is computed from these exactly, with no
-- second decode (#4996).
block_hist_start smallint,
block_hist integer[],
unreadable boolean NOT NULL DEFAULT false,
analysis_version smallint NOT NULL,
analyzed_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT track_loudness_hist_pair
CHECK ((block_hist IS NULL) = (block_hist_start IS NULL))
);
-- Loudness analysis's knobs, in admin Settings. Rule 25: an operator setting is
-- a database row, changed without a restart. Singleton in the style of
-- fingerprint_settings (0061).
CREATE TABLE loudness_settings (
id boolean PRIMARY KEY DEFAULT true,
-- Off stops the background analysis. Tracks already measured keep their
-- values, so normalization keeps working for them.
enabled boolean NOT NULL DEFAULT true,
-- Files analyzed at once. Each is a full decode, competing with playback
-- transcoding for CPU and with streaming for the mount.
backfill_concurrency integer NOT NULL DEFAULT 2,
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT loudness_settings_singleton CHECK (id = true),
CONSTRAINT loudness_settings_concurrency_range
CHECK (backfill_concurrency >= 1 AND backfill_concurrency <= 8)
);
INSERT INTO loudness_settings (id) VALUES (true) ON CONFLICT (id) DO NOTHING;