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>
251 lines
8.6 KiB
Go
251 lines
8.6 KiB
Go
package library
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"os"
|
|
"os/exec"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// parseEbur128 runs the parser over a whole log, as computeLoudness does over
|
|
// ffmpeg's stderr.
|
|
func parseEbur128(log string) *ebur128Parser {
|
|
p := newEbur128Parser()
|
|
p.consume(strings.NewReader(log))
|
|
return p
|
|
}
|
|
|
|
// The fixture is real ffmpeg 6.1 output for 12 s of stereo tone whose first
|
|
// second is digital silence (testdata/ebur128_fixture.txt). Pinning the parser
|
|
// to captured output, not to a hand-written imitation of it, is the point: the
|
|
// per-block lines are where a format drift would hide.
|
|
func TestEbur128Parser_RealOutput(t *testing.T) {
|
|
raw, err := os.ReadFile("testdata/ebur128_fixture.txt")
|
|
if err != nil {
|
|
t.Fatalf("read fixture: %v", err)
|
|
}
|
|
r := parseEbur128(string(raw)).result()
|
|
if r.err != nil {
|
|
t.Fatalf("result err = %v", r.err)
|
|
}
|
|
for name, c := range map[string]struct {
|
|
got *float32
|
|
want float32
|
|
}{
|
|
"integrated": {r.integratedLUFS, -10.7},
|
|
"true peak": {r.truePeakDBTP, -5.6},
|
|
"range": {r.rangeLU, 2.0},
|
|
} {
|
|
if c.got == nil || *c.got != c.want {
|
|
t.Errorf("%s = %v, want %v", name, c.got, c.want)
|
|
}
|
|
}
|
|
|
|
// 120 blocks, of which the 10 covering the silent second are below the
|
|
// absolute gate and not counted. The loudest is -8.1 LUFS, the quietest
|
|
// that passed -22.6.
|
|
var blocks int32
|
|
for _, c := range r.hist.counts {
|
|
blocks += c
|
|
}
|
|
if blocks != 110 {
|
|
t.Errorf("histogram holds %d blocks, want 110", blocks)
|
|
}
|
|
if r.hist.start != 474 || len(r.hist.counts) != 146 {
|
|
t.Errorf("histogram spans bins %d..%d, want 474..619 (-22.6..-8.1 LUFS)",
|
|
r.hist.start, int(r.hist.start)+len(r.hist.counts)-1)
|
|
}
|
|
if r.hist.counts[0] == 0 || r.hist.counts[len(r.hist.counts)-1] == 0 {
|
|
t.Errorf("histogram not trimmed to its occupied bins: %v", r.hist.counts)
|
|
}
|
|
|
|
// Album loudness is computed from these histograms (#4996), so the
|
|
// histogram has to reproduce ffmpeg's own figure.
|
|
got, ok := r.hist.gatedLoudness()
|
|
if !ok || math.Abs(got-(-10.7)) > 0.05 {
|
|
t.Errorf("loudness from the histogram = %.3f (ok=%v), want ffmpeg's -10.7 within 0.05", got, ok)
|
|
}
|
|
}
|
|
|
|
func TestEbur128Parser_SilenceIsAVerdictNotAMeasurement(t *testing.T) {
|
|
log := `[Parsed_ebur128_0 @ 0x1] t: 0.1 TARGET:-23 LUFS M:-120.7 S:-120.7 I: -70.0 LUFS LRA: 0.0 LU FTPK: -inf -inf dBFS TPK: -inf -inf dBFS
|
|
[Parsed_ebur128_0 @ 0x1] t: 0.2 TARGET:-23 LUFS M:-120.7 S:-120.7 I: -70.0 LUFS LRA: 0.0 LU FTPK: -inf -inf dBFS TPK: -inf -inf dBFS
|
|
[Parsed_ebur128_0 @ 0x1] Summary:
|
|
|
|
Integrated loudness:
|
|
I: -70.0 LUFS
|
|
Threshold: 0.0 LUFS
|
|
|
|
Loudness range:
|
|
LRA: 0.0 LU
|
|
Threshold: 0.0 LUFS
|
|
LRA low: 0.0 LUFS
|
|
LRA high: 0.0 LUFS
|
|
|
|
True peak:
|
|
Peak: -inf dBFS
|
|
`
|
|
r := parseEbur128(log).result()
|
|
if r.err != nil {
|
|
t.Fatalf("err = %v, want a clean result", r.err)
|
|
}
|
|
if r.integratedLUFS != nil || r.truePeakDBTP != nil || r.rangeLU != nil {
|
|
t.Errorf("silence measured as integrated=%v peak=%v range=%v, want all nil",
|
|
r.integratedLUFS, r.truePeakDBTP, r.rangeLU)
|
|
}
|
|
if !r.hist.empty() {
|
|
t.Errorf("silence left blocks in the histogram: %+v", r.hist)
|
|
}
|
|
if r.inconclusive() {
|
|
t.Errorf("silence reported as inconclusive; it is settled until the file changes")
|
|
}
|
|
}
|
|
|
|
func TestEbur128Parser_NoSummaryIsUnreadable(t *testing.T) {
|
|
r := parseEbur128("[in#0 @ 0x1] Error opening input: Invalid data found when processing input\n").result()
|
|
if r.err == nil {
|
|
t.Fatal("a log with no summary produced a measurement")
|
|
}
|
|
if r.inconclusive() {
|
|
t.Errorf("err %v reads as inconclusive; ffmpeg ran and found nothing to measure", r.err)
|
|
}
|
|
if !strings.Contains(r.err.Error(), "Invalid data found") {
|
|
t.Errorf("err %q does not carry ffmpeg's reason", r.err)
|
|
}
|
|
}
|
|
|
|
func TestEbur128Parser_LoudBlocksLandInTheTopBin(t *testing.T) {
|
|
p := parseEbur128(`[Parsed_ebur128_0 @ 0x1] t: 0.4 TARGET:-23 LUFS M: 12.4 S: 12.4 I: 12.4 LUFS
|
|
[Parsed_ebur128_0 @ 0x1] t: 0.5 TARGET:-23 LUFS M: -5.0 S: -5.0 I: -5.0 LUFS
|
|
`)
|
|
h := p.histogram()
|
|
if int(h.start)+len(h.counts) != loudnessHistBins || h.counts[len(h.counts)-1] != 1 {
|
|
t.Errorf("a +12.4 LUFS block was not counted in the top bin: %+v", h)
|
|
}
|
|
if h.start != 650 || h.counts[0] != 1 {
|
|
t.Errorf("a -5.0 LUFS block is not in bin 650: start %d, first %d", h.start, h.counts[0])
|
|
}
|
|
}
|
|
|
|
func TestBlockHistogram_GatedLoudness(t *testing.T) {
|
|
bin := func(lufs float64) int { return int(math.Round((lufs - loudnessHistFloor) * loudnessHistPerLU)) }
|
|
hist := func(blocks map[float64]int32) blockHistogram {
|
|
lo, hi := loudnessHistBins, 0
|
|
for l := range blocks {
|
|
lo, hi = min(lo, bin(l)), max(hi, bin(l))
|
|
}
|
|
h := blockHistogram{start: int16(lo), counts: make([]int32, hi-lo+1)}
|
|
for l, n := range blocks {
|
|
h.counts[bin(l)-lo] = n
|
|
}
|
|
return h
|
|
}
|
|
|
|
cases := []struct {
|
|
name string
|
|
blocks map[float64]int32
|
|
want float64
|
|
}{
|
|
// One level throughout is that level.
|
|
{"steady", map[float64]int32{-14: 50}, -14},
|
|
// The quiet half is 30 LU below the loud half, past the relative gate
|
|
// (10 LU below the ungated loudness, here about -13), so it is dropped
|
|
// and the result is the loud half alone.
|
|
{"quiet passage gated out", map[float64]int32{-10: 100, -40: 100}, -10},
|
|
// 6 LU apart is inside the gate, so both count, energy-weighted: the
|
|
// result sits nearer the louder level than the midpoint (-15) does.
|
|
{"both inside the gate", map[float64]int32{-12: 100, -18: 100}, -14.037},
|
|
}
|
|
for _, c := range cases {
|
|
got, ok := hist(c.blocks).gatedLoudness()
|
|
if !ok || math.Abs(got-c.want) > 0.01 {
|
|
t.Errorf("%s: gatedLoudness = %.3f (ok=%v), want %.3f", c.name, got, ok, c.want)
|
|
}
|
|
}
|
|
if _, ok := (blockHistogram{}).gatedLoudness(); ok {
|
|
t.Errorf("an empty histogram reported a loudness")
|
|
}
|
|
}
|
|
|
|
func TestEbur128Args(t *testing.T) {
|
|
args := ebur128Args("/music/a.flac")
|
|
joined := strings.Join(args, " ")
|
|
for _, want := range []string{"peak=true", "dualmono=true", "framelog=info", "-map 0:a:0", "-nostdin", "-f null -"} {
|
|
if !strings.Contains(joined, want) {
|
|
t.Errorf("args %q lack %q", joined, want)
|
|
}
|
|
}
|
|
// The path is its own argument, never spliced into the filter string.
|
|
if i := slices.Index(args, "-i"); i < 0 || args[i+1] != "/music/a.flac" {
|
|
t.Errorf("args %q do not pass the path after -i", args)
|
|
}
|
|
}
|
|
|
|
func TestLoudnessTimeout_ScalesWithLength(t *testing.T) {
|
|
if got := loudnessTimeout(0); got != loudnessBaseTimeout {
|
|
t.Errorf("unknown length: %s, want the base %s", got, loudnessBaseTimeout)
|
|
}
|
|
if got, want := loudnessTimeout(int32((2 * time.Hour).Milliseconds())), loudnessBaseTimeout+30*time.Minute; got != want {
|
|
t.Errorf("two-hour mix: %s, want %s", got, want)
|
|
}
|
|
if got := loudnessTimeout(-5); got != loudnessBaseTimeout {
|
|
t.Errorf("negative length: %s, want the base %s", got, loudnessBaseTimeout)
|
|
}
|
|
}
|
|
|
|
func TestLoudnessResult_Inconclusive(t *testing.T) {
|
|
for _, err := range []error{
|
|
fmt.Errorf("ffmpeg: %w", errLoudnessTimeout),
|
|
fmt.Errorf("ffmpeg: %w", context.Canceled),
|
|
fmt.Errorf("ffmpeg: %w", exec.ErrNotFound),
|
|
} {
|
|
if !(loudnessResult{err: err}).inconclusive() {
|
|
t.Errorf("%v: not inconclusive, so it would be stored as a verdict", err)
|
|
}
|
|
}
|
|
if (loudnessResult{err: errors.New("ffmpeg exited 1: moov atom not found")}).inconclusive() {
|
|
t.Errorf("a decode failure read as inconclusive; it would be retried every pass")
|
|
}
|
|
if (loudnessResult{}).inconclusive() {
|
|
t.Errorf("a clean result read as inconclusive")
|
|
}
|
|
}
|
|
|
|
func TestBackfillLoudnessResult_Add(t *testing.T) {
|
|
var r BackfillLoudnessResult
|
|
for _, o := range []loudnessOutcome{
|
|
loudnessMeasured, loudnessMeasured, loudnessSilent, loudnessUnreadable,
|
|
loudnessInconclusive, loudnessStoreFailed,
|
|
} {
|
|
r.add(o)
|
|
}
|
|
// A failed write stored nothing, so it is retried like an inconclusive one.
|
|
want := BackfillLoudnessResult{Processed: 6, Measured: 2, Silent: 1, Unreadable: 1, Inconclusive: 2}
|
|
if r != want {
|
|
t.Errorf("tally = %+v, want %+v", r, want)
|
|
}
|
|
}
|
|
|
|
func TestValidateLoudnessSettings(t *testing.T) {
|
|
for _, n := range []int32{minBackfillConcurrency, maxBackfillConcurrency} {
|
|
if err := validateLoudnessSettings(LoudnessSettings{BackfillConcurrency: n}); err != nil {
|
|
t.Errorf("concurrency %d rejected: %v", n, err)
|
|
}
|
|
}
|
|
for _, n := range []int32{0, maxBackfillConcurrency + 1} {
|
|
if err := validateLoudnessSettings(LoudnessSettings{BackfillConcurrency: n}); !errors.Is(err, ErrLoudnessSettingOutOfRange) {
|
|
t.Errorf("concurrency %d: err = %v, want ErrLoudnessSettingOutOfRange", n, err)
|
|
}
|
|
}
|
|
var nilSvc *LoudnessSettingsService
|
|
if got := nilSvc.Get(); got != DefaultLoudnessSettings {
|
|
t.Errorf("nil service Get = %+v, want the defaults", got)
|
|
}
|
|
}
|