Files
minstrel/internal/library/loudness.go
T
bvandeusenandClaude Opus 5.5 c2f81bf8df
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
feat(library): album loudness from the tracks' summed block histograms (M464 #4996)
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>
2026-10-06 13:27:02 -04:00

397 lines
13 KiB
Go

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
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)]++
}
func (p *ebur128Parser) tail() string {
return strings.Join(p.lastLines, " | ")
}
func (p *ebur128Parser) histogram() blockHistogram {
return trimBins(&p.bins)
}
// trimBins turns a full-range bin array into a histogram trimmed to its
// occupied bins; an empty array gives an empty histogram.
func trimBins(bins *[loudnessHistBins]int32) blockHistogram {
first, last := 0, loudnessHistBins-1
for first <= last && bins[first] == 0 {
first++
}
if first > last {
return blockHistogram{}
}
for bins[last] == 0 {
last--
}
counts := make([]int32, last-first+1)
copy(counts, 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
}