release / go (push) Successful in 2m17s
release / govulncheck (push) Successful in 26s
release / web (push) Successful in 2m4s
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 / integration (push) Canceled after 4m39s
release / android (push) Canceled after 2m53s
release / Build signed APK (releases and dev) (push) Canceled after 2m24s
Speakers fetch their own audio, so the phone cannot level it. A cast
token minted with level=true (and the client's asAlbum, which only the
queue holder knows) now returns GET /api/tracks/{id}/leveled.flac: the
track rendered by ffmpeg at the user's gain (volume=XdB, plus
alimiter at -1 dBFS for a limiter-mode boost), metadata stripped, FLAC
at 16 or 24 bits and at most 48 kHz. The gain is computed server-side
from the user's preference and the stored loudness, carried as
?g=<centi-dB>&lim=0|1 and signed into the token, so an edited URL does
not verify. Unity gains get the plain stream.
Renders are written beside the cache file and renamed in, keyed by the
source's size and mtime, coalesced per file (singleflight, detached
from the requesting speaker so a retry finds the render running),
started at mint time so the fetch finds them ready, and evicted least
recently used past leveled_cache_mb, a new admin setting (migration
0068, Loudness analysis card).
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 (#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) {
|
|
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)
|
|
}
|
|
}
|