release / govulncheck (push) Successful in 20s
release / web (push) Successful in 1m20s
release / go (push) Successful in 1m37s
release / integration (push) Successful in 5m15s
release / android (push) Successful in 5m26s
release / Build signed APK (releases and dev) (push) Successful in 5m45s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 16s
release / Verify release artifacts (tag releases only) (push) Skipped
Rewrites the internal/library comment blocks to rule 32. Task, milestone, decision and rule ids are gone; the incidents behind them are restated as the constraint they left (why a file is deleted before its row, why the missing mark is a mark, why duplicates block on duration). Counts and album names from one library are dropped from production comments; test comments keep the names their fixtures use. The tagReadVersion log keeps each version's before and after, without the measurements. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
264 lines
8.9 KiB
Go
264 lines
8.9 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, 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) {
|
|
with := func(concurrency, cacheMB int32) LoudnessSettings {
|
|
s := DefaultLoudnessSettings
|
|
s.BackfillConcurrency, s.LeveledCacheMB = concurrency, cacheMB
|
|
return s
|
|
}
|
|
for _, s := range []LoudnessSettings{
|
|
with(minBackfillConcurrency, minLeveledCacheMB),
|
|
with(maxBackfillConcurrency, maxLeveledCacheMB),
|
|
} {
|
|
if err := validateLoudnessSettings(s); err != nil {
|
|
t.Errorf("%+v rejected: %v", s, err)
|
|
}
|
|
}
|
|
for _, s := range []LoudnessSettings{
|
|
with(0, minLeveledCacheMB),
|
|
with(maxBackfillConcurrency+1, minLeveledCacheMB),
|
|
with(minBackfillConcurrency, minLeveledCacheMB-1),
|
|
with(minBackfillConcurrency, maxLeveledCacheMB+1),
|
|
} {
|
|
if err := validateLoudnessSettings(s); !errors.Is(err, ErrLoudnessSettingOutOfRange) {
|
|
t.Errorf("%+v: err = %v, want ErrLoudnessSettingOutOfRange", s, err)
|
|
}
|
|
}
|
|
var nilSvc *LoudnessSettingsService
|
|
if got := nilSvc.Get(); got != DefaultLoudnessSettings {
|
|
t.Errorf("nil service Get = %+v, want the defaults", got)
|
|
}
|
|
}
|