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) } }