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,392 @@
|
||||
package library
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"math"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
|
||||
// Loudness analysis (M464 #4995).
|
||||
//
|
||||
// Every track is measured with ffmpeg's EBU R128 filter, which reports the
|
||||
// ITU-R BS.1770 values loudness normalization works from:
|
||||
//
|
||||
// integrated loudness the gated, K-weighted average loudness of the whole
|
||||
// track, in LUFS. A client levels a track by playing it
|
||||
// at (target - integrated) dB.
|
||||
// true peak the highest inter-sample peak, in dBTP. It bounds how
|
||||
// far a quiet track can be raised before it clips.
|
||||
// loudness range how much the loudness moves within the track.
|
||||
//
|
||||
// Tags are not trusted: ReplayGain values in the wild are written against four
|
||||
// different reference levels, and most files have none.
|
||||
//
|
||||
// The filter also logs the loudness of every 400 ms gating block (one every
|
||||
// 100 ms). Those are kept as a histogram, because an album's loudness is the
|
||||
// gated loudness of every block on the album, not an average of its tracks'
|
||||
// values. With the histograms stored, album loudness is exact and needs no
|
||||
// second decode (#4996).
|
||||
|
||||
// loudnessVersion stamps how a track_loudness row was measured. Bump it when
|
||||
// the measurement changes (the filter's options, the histogram's bins) and the
|
||||
// backfill measures every row below it again.
|
||||
const loudnessVersion int16 = 1
|
||||
|
||||
// Unlike a fingerprint, the analysis decodes the whole file, so a fixed
|
||||
// deadline would either cut off a long mix or be useless for a three-minute
|
||||
// song. The deadline is a base plus the track's length at loudnessMinSpeed:
|
||||
// a decode slower than that is a stall, not a big file.
|
||||
const (
|
||||
loudnessBaseTimeout = 2 * time.Minute
|
||||
loudnessMinSpeed = 4
|
||||
)
|
||||
|
||||
// errLoudnessTimeout marks an analysis that ran out of time: a fact about the
|
||||
// mount, not about the file.
|
||||
var errLoudnessTimeout = errors.New("loudness analysis timed out")
|
||||
|
||||
// The block histogram's bins: 0.1 LU wide (the precision ffmpeg prints) from
|
||||
// the -70 LUFS absolute gate up to +10 LUFS. Blocks below the gate do not take
|
||||
// part in gating at all, so they are not counted; anything above the top bin,
|
||||
// which only clipped noise reaches, is counted in it.
|
||||
const (
|
||||
loudnessHistFloor = -70.0
|
||||
loudnessHistPerLU = 10
|
||||
loudnessHistBins = 800
|
||||
loudnessStderrTail = 20 // lines kept for an error message
|
||||
)
|
||||
|
||||
// histogramCrossCheckLU is how far the loudness recomputed from the histogram
|
||||
// may stray from ffmpeg's own figure before it is logged. They agree to a few
|
||||
// hundredths when the log means what the parser assumes, so a larger gap says
|
||||
// ffmpeg's output has changed underneath us.
|
||||
const histogramCrossCheckLU = 0.3
|
||||
|
||||
// ebur128Args measures the file's first audio stream.
|
||||
//
|
||||
// peak=true asks for true peak (4x oversampled) rather than sample peak, which
|
||||
// misses the inter-sample overs a boosted track would clip on. dualmono=true
|
||||
// measures a mono file as if played on both speakers of a stereo pair, which is
|
||||
// how it is heard; measured as one channel it would read 3 LU quiet and be
|
||||
// boosted too far. framelog=info makes the per-block lines print at the log
|
||||
// level asked for here, independent of ffmpeg's default.
|
||||
func ebur128Args(path string) []string {
|
||||
return []string{
|
||||
"-hide_banner", "-nostdin", "-nostats",
|
||||
"-loglevel", "info",
|
||||
"-i", path,
|
||||
"-map", "0:a:0",
|
||||
"-af", "ebur128=peak=true:dualmono=true:framelog=info",
|
||||
"-f", "null", "-",
|
||||
}
|
||||
}
|
||||
|
||||
// loudnessTimeout is the deadline for a track of durationMs. An unknown length
|
||||
// (0) gets the base alone, which covers an ordinary song.
|
||||
func loudnessTimeout(durationMs int32) time.Duration {
|
||||
return loudnessBaseTimeout + time.Duration(max(durationMs, 0))*time.Millisecond/loudnessMinSpeed
|
||||
}
|
||||
|
||||
// loudnessResult is one attempt at measuring a track.
|
||||
type loudnessResult struct {
|
||||
// integratedLUFS is nil when no block passed the gates: silence, or a
|
||||
// file shorter than one 400 ms block.
|
||||
integratedLUFS *float32
|
||||
truePeakDBTP *float32
|
||||
rangeLU *float32
|
||||
hist blockHistogram
|
||||
err error
|
||||
}
|
||||
|
||||
// inconclusive reports whether the attempt failed for a reason that says
|
||||
// nothing about the file. Such a result is never stored: stamped at the current
|
||||
// version it would read as "this file cannot be measured", and the backfill
|
||||
// would never try it again.
|
||||
func (r loudnessResult) inconclusive() bool {
|
||||
return isInconclusive(r.err) || errors.Is(r.err, errLoudnessTimeout)
|
||||
}
|
||||
|
||||
// blockHistogram counts gating blocks per 0.1 LU bin, trimmed to the occupied
|
||||
// range: counts[i] is the number of blocks measuring
|
||||
// loudnessHistFloor + (start+i)/loudnessHistPerLU LUFS.
|
||||
type blockHistogram struct {
|
||||
start int16
|
||||
counts []int32
|
||||
}
|
||||
|
||||
func (h blockHistogram) empty() bool { return len(h.counts) == 0 }
|
||||
|
||||
// gatedLoudness applies BS.1770's two gates to the histogram and returns the
|
||||
// integrated loudness of what passes. Every counted block is already above the
|
||||
// absolute gate; the relative gate drops blocks more than 10 LU below the
|
||||
// loudness of the blocks that passed it. ok is false when nothing passes.
|
||||
//
|
||||
// The same function measures an album, over the sum of its tracks'
|
||||
// histograms (#4996).
|
||||
func (h blockHistogram) gatedLoudness() (lufs float64, ok bool) {
|
||||
energy := func(i int) float64 {
|
||||
l := loudnessHistFloor + float64(int(h.start)+i)/loudnessHistPerLU
|
||||
return math.Pow(10, (l+0.691)/10)
|
||||
}
|
||||
mean := func(threshold float64) (float64, bool) {
|
||||
var sum, n float64
|
||||
for i, c := range h.counts {
|
||||
if c == 0 {
|
||||
continue
|
||||
}
|
||||
if loudnessHistFloor+float64(int(h.start)+i)/loudnessHistPerLU < threshold {
|
||||
continue
|
||||
}
|
||||
sum += float64(c) * energy(i)
|
||||
n += float64(c)
|
||||
}
|
||||
if n == 0 {
|
||||
return 0, false
|
||||
}
|
||||
return sum / n, true
|
||||
}
|
||||
ungated, ok := mean(math.Inf(-1))
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
relative := -0.691 + 10*math.Log10(ungated) - 10
|
||||
gated, ok := mean(relative)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
return -0.691 + 10*math.Log10(gated), true
|
||||
}
|
||||
|
||||
// computeLoudness measures the file at path. durationMs sets the deadline.
|
||||
func computeLoudness(ctx context.Context, path string, durationMs int32) loudnessResult {
|
||||
timeout := loudnessTimeout(durationMs)
|
||||
runCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(runCtx, "ffmpeg", ebur128Args(path)...)
|
||||
cmd.WaitDelay = fingerprintWaitDelay
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
return loudnessResult{err: fmt.Errorf("ffmpeg: %w", err)}
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return loudnessResult{err: fmt.Errorf("ffmpeg: %w", err)}
|
||||
}
|
||||
// The per-block log runs to ten lines a second of audio, about 12 MB for a
|
||||
// two-hour mix, so it is parsed as it streams rather than buffered.
|
||||
p := newEbur128Parser()
|
||||
p.consume(stderr)
|
||||
waitErr := cmd.Wait()
|
||||
|
||||
switch {
|
||||
case ctx.Err() != nil:
|
||||
// The caller gave up. Report that rather than the kill it caused, so it
|
||||
// is never mistaken for a verdict on the file.
|
||||
return loudnessResult{err: fmt.Errorf("ffmpeg: %w", ctx.Err())}
|
||||
case errors.Is(runCtx.Err(), context.DeadlineExceeded):
|
||||
return loudnessResult{err: fmt.Errorf("ffmpeg: no result within %s: %w", timeout, errLoudnessTimeout)}
|
||||
case waitErr != nil:
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(waitErr, &exitErr) {
|
||||
return loudnessResult{err: fmt.Errorf("ffmpeg exited %d: %s", exitErr.ExitCode(), p.tail())}
|
||||
}
|
||||
return loudnessResult{err: fmt.Errorf("ffmpeg: %w", waitErr)}
|
||||
}
|
||||
return p.result()
|
||||
}
|
||||
|
||||
var (
|
||||
// A per-block line: "[Parsed_ebur128_0 @ 0x…] t: 2.49998 TARGET:-23 LUFS
|
||||
// M: -31.1 S:-120.7 I: -31.1 LUFS ...". M is the 400 ms block just ended.
|
||||
ebur128BlockRe = regexp.MustCompile(`\bt:\s*\S+\s+TARGET:.*?\bM:\s*(-?(?:\d+(?:\.\d+)?|inf))`)
|
||||
// The summary's values, each on a line of its own after "Summary:".
|
||||
ebur128SummaryRe = regexp.MustCompile(`^(I|LRA|Peak):\s+(-?(?:\d+(?:\.\d+)?|inf))\s+(?:LUFS|LU|dBFS)$`)
|
||||
)
|
||||
|
||||
// ebur128Parser reads the filter's log: the per-block lines into the
|
||||
// histogram, and the closing summary.
|
||||
type ebur128Parser struct {
|
||||
bins [loudnessHistBins]int32
|
||||
blocks int
|
||||
inSummary bool
|
||||
summary map[string]string
|
||||
lastLines []string
|
||||
}
|
||||
|
||||
func newEbur128Parser() *ebur128Parser {
|
||||
return &ebur128Parser{summary: map[string]string{}}
|
||||
}
|
||||
|
||||
func (p *ebur128Parser) consume(r io.Reader) {
|
||||
sc := bufio.NewScanner(r)
|
||||
sc.Buffer(make([]byte, 0, 4096), 64*1024)
|
||||
for sc.Scan() {
|
||||
p.line(sc.Text())
|
||||
}
|
||||
// A line past the buffer (not something ffmpeg prints) stops the scanner;
|
||||
// drain the rest so ffmpeg is never blocked writing to a full pipe.
|
||||
_, _ = io.Copy(io.Discard, r)
|
||||
}
|
||||
|
||||
func (p *ebur128Parser) line(raw string) {
|
||||
line := strings.TrimSpace(strings.TrimRight(raw, "\r"))
|
||||
if line == "" {
|
||||
return
|
||||
}
|
||||
if len(p.lastLines) == loudnessStderrTail {
|
||||
p.lastLines = p.lastLines[1:]
|
||||
}
|
||||
p.lastLines = append(p.lastLines, line)
|
||||
|
||||
if strings.HasSuffix(line, "Summary:") {
|
||||
p.inSummary = true
|
||||
return
|
||||
}
|
||||
if p.inSummary {
|
||||
if m := ebur128SummaryRe.FindStringSubmatch(line); m != nil {
|
||||
p.summary[m[1]] = m[2]
|
||||
}
|
||||
return
|
||||
}
|
||||
m := ebur128BlockRe.FindStringSubmatch(line)
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
v, err := strconv.ParseFloat(m[1], 64)
|
||||
if err != nil || v < loudnessHistFloor {
|
||||
// -inf, or below the absolute gate: such a block takes no part in
|
||||
// gating.
|
||||
return
|
||||
}
|
||||
bin := int(math.Round((v - loudnessHistFloor) * loudnessHistPerLU))
|
||||
p.bins[min(bin, loudnessHistBins-1)]++
|
||||
p.blocks++
|
||||
}
|
||||
|
||||
func (p *ebur128Parser) tail() string {
|
||||
return strings.Join(p.lastLines, " | ")
|
||||
}
|
||||
|
||||
func (p *ebur128Parser) histogram() blockHistogram {
|
||||
if p.blocks == 0 {
|
||||
return blockHistogram{}
|
||||
}
|
||||
first, last := 0, loudnessHistBins-1
|
||||
for p.bins[first] == 0 {
|
||||
first++
|
||||
}
|
||||
for p.bins[last] == 0 {
|
||||
last--
|
||||
}
|
||||
counts := make([]int32, last-first+1)
|
||||
copy(counts, p.bins[first:last+1])
|
||||
return blockHistogram{start: int16(first), counts: counts}
|
||||
}
|
||||
|
||||
// result turns a clean exit into a measurement. A run with no summary means
|
||||
// ffmpeg decoded nothing it could measure, which is a verdict on the file.
|
||||
func (p *ebur128Parser) result() loudnessResult {
|
||||
integrated, ok := p.summary["I"]
|
||||
if !ok {
|
||||
return loudnessResult{err: fmt.Errorf("ffmpeg printed no loudness summary: %s", p.tail())}
|
||||
}
|
||||
r := loudnessResult{
|
||||
hist: p.histogram(),
|
||||
truePeakDBTP: parseLoudnessValue(p.summary["Peak"]),
|
||||
rangeLU: parseLoudnessValue(p.summary["LRA"]),
|
||||
}
|
||||
// ffmpeg reports -70.0 when no block passed the gate. The empty histogram
|
||||
// says the same thing directly.
|
||||
if !r.hist.empty() {
|
||||
r.integratedLUFS = parseLoudnessValue(integrated)
|
||||
}
|
||||
if r.integratedLUFS == nil {
|
||||
r.rangeLU = nil
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// parseLoudnessValue reads one summary figure; -inf and anything unreadable
|
||||
// come back nil.
|
||||
func parseLoudnessValue(s string) *float32 {
|
||||
v, err := strconv.ParseFloat(s, 32)
|
||||
if err != nil || math.IsInf(v, 0) || math.IsNaN(v) {
|
||||
return nil
|
||||
}
|
||||
f := float32(v)
|
||||
return &f
|
||||
}
|
||||
|
||||
// loudnessOutcome is what storeLoudness did with one attempt.
|
||||
type loudnessOutcome int
|
||||
|
||||
const (
|
||||
loudnessMeasured loudnessOutcome = iota // integrated loudness stored
|
||||
loudnessSilent // read fine; no block above the gate
|
||||
loudnessUnreadable // ffmpeg could not decode the file
|
||||
loudnessInconclusive // nothing stored; worth trying again
|
||||
loudnessStoreFailed // the write itself failed
|
||||
)
|
||||
|
||||
// storeLoudness records one attempt. It never fails its caller: an unmeasured
|
||||
// track simply plays without a gain adjustment.
|
||||
//
|
||||
// An inconclusive attempt leaves any existing row alone. The scan deletes a
|
||||
// row when its file changes, so a row still here describes these bytes, and a
|
||||
// value from an older method is a better gain than none until it is redone.
|
||||
func storeLoudness(
|
||||
ctx context.Context, q *dbq.Queries, logger *slog.Logger,
|
||||
trackID pgtype.UUID, path string, r loudnessResult,
|
||||
) loudnessOutcome {
|
||||
if r.err != nil {
|
||||
logger.Warn("loudness: analysis failed", "path", path, "err", r.err)
|
||||
if r.inconclusive() {
|
||||
return loudnessInconclusive
|
||||
}
|
||||
}
|
||||
params := dbq.UpsertTrackLoudnessParams{
|
||||
TrackID: trackID,
|
||||
Unreadable: r.err != nil,
|
||||
AnalysisVersion: loudnessVersion,
|
||||
}
|
||||
outcome := loudnessUnreadable
|
||||
if r.err == nil {
|
||||
outcome = loudnessSilent
|
||||
params.IntegratedLufs = r.integratedLUFS
|
||||
params.TruePeakDbtp = r.truePeakDBTP
|
||||
params.LoudnessRangeLu = r.rangeLU
|
||||
if !r.hist.empty() {
|
||||
start := r.hist.start
|
||||
params.BlockHistStart = &start
|
||||
params.BlockHist = r.hist.counts
|
||||
}
|
||||
if r.integratedLUFS != nil {
|
||||
outcome = loudnessMeasured
|
||||
if got, ok := r.hist.gatedLoudness(); ok && math.Abs(got-float64(*r.integratedLUFS)) > histogramCrossCheckLU {
|
||||
// Stored regardless: ffmpeg's own figure is the track's
|
||||
// loudness. But album loudness is computed from the
|
||||
// histogram, and this says it would be wrong.
|
||||
logger.Warn("loudness: block histogram disagrees with ffmpeg's integrated loudness",
|
||||
"path", path, "ffmpeg_lufs", *r.integratedLUFS, "histogram_lufs", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := q.UpsertTrackLoudness(ctx, params); err != nil {
|
||||
logger.Warn("loudness: storing measurement failed", "path", path, "err", err)
|
||||
return loudnessStoreFailed
|
||||
}
|
||||
return outcome
|
||||
}
|
||||
Reference in New Issue
Block a user