1package vchan23import (4 "bytes"5 "context"6 "io"7 "math"8 "math/rand/v2"9 "os"10 "os/exec"11 "path/filepath"12 "strconv"13 "strings"14 "testing"15 "time"16)1718// pcm makes secs of 48 kHz stereo s16le from f(t) for each channel.19func pcm(secs float64, f func(t float64) (l, r float64)) []byte {20 n := int(secs * sampleRate)21 b := make([]byte, 4*n)22 for i := range n {23 l, r := f(float64(i) / sampleRate)24 putSample(b[4*i:], l)25 putSample(b[4*i+2:], r)26 }27 return b28}2930// sine is a 1 kHz tone peaking at dBFS, on both channels or the left.31func sine(dbfs float64, both bool) func(float64) (float64, float64) {32 a := math.Pow(10, dbfs/20)33 return func(t float64) (float64, float64) {34 x := a * math.Sin(2*math.Pi*1000*t)35 if both {36 return x, x37 }38 return x, 039 }40}4142// noise is noise at about dbfs RMS, the same each run.43func noise(dbfs float64, seed uint64) func(float64) (float64, float64) {44 rng := rand.New(rand.NewPCG(seed, 1))45 a := math.Pow(10, dbfs/20) * math.Sqrt(3)46 return func(float64) (float64, float64) {47 return a * (2*rng.Float64() - 1), a * (2*rng.Float64() - 1)48 }49}5051// measured is the gated loudness of raw sound, and its loudest sample.52func measured(b []byte) (lufs, peak float64) {53 var m meter54 for i := 0; i+4 <= len(b); i += 4 {55 l := float64(int16(uint16(b[i])|uint16(b[i+1])<<8)) / 3276856 r := float64(int16(uint16(b[i+2])|uint16(b[i+3])<<8)) / 3276857 m.add(l, r)58 peak = max(peak, math.Abs(l), math.Abs(r))59 }60 lufs, _ = m.integrated()61 return lufs, peak62}6364func TestMeterReference(t *testing.T) {65 near := func(name string, got, want, tol float64) {66 t.Helper()67 if math.IsNaN(got) || math.Abs(got-want) > tol {68 t.Errorf("%s: %.2f LUFS, want %.2f", name, got, want)69 }70 }71 // BS.1770: a 1 kHz sine at 0 dBFS in one channel reads -3.01 LKFS.72 l, _ := measured(pcm(10, sine(-20, false)))73 near("-20 dBFS sine, left only", l, -23.01, 0.1)74 l, _ = measured(pcm(10, sine(-20, true)))75 near("-20 dBFS sine, both", l, -20, 0.1)76 // Silence is under the absolute gate, and sound 20 LU down is under77 // the relative one.78 l, _ = measured(append(pcm(10, sine(-20, true)), pcm(10, func(float64) (float64, float64) { return 0, 0 })...))79 near("half silence", l, -20, 0.1)80 l, _ = measured(append(pcm(10, sine(-20, true)), pcm(10, sine(-40, true))...))81 near("half 20 LU down", l, -20, 0.1)82 l, _ = measured(append(pcm(10, sine(-20, true)), pcm(10, sine(-26, true))...))83 near("half 6 LU down", l, -22.04, 0.1)84 if l, _ := measured(pcm(2, func(float64) (float64, float64) { return 0, 0 })); !math.IsNaN(l) {85 t.Errorf("silence: %v, want none", l)86 }87}8889// TestMeterMatchesFFmpeg meters pink noise and compares with ffmpeg's90// ebur128.91func TestMeterMatchesFFmpeg(t *testing.T) {92 ffmpeg, err := exec.LookPath("ffmpeg")93 if err != nil {94 t.Skip("no ffmpeg")95 }96 src := "anoisesrc=color=pink:amplitude=0.3:d=12:sample_rate=48000"97 raw, err := exec.Command(ffmpeg, "-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", src,98 "-af", "pan=stereo|c0=c0|c1=c0", "-f", "s16le", "-ar", "48000", "-ac", "2", "-").Output()99 if err != nil {100 t.Fatal(err)101 }102 out, _ := exec.Command(ffmpeg, "-hide_banner", "-nostats", "-f", "lavfi", "-i", src,103 "-af", "pan=stereo|c0=c0|c1=c0,ebur128", "-f", "null", "-").CombinedOutput()104 i := strings.LastIndex(string(out), "I:")105 if i < 0 {106 t.Fatalf("no ebur128 summary: %s", out)107 }108 want, _ := strconv.ParseFloat(strings.Fields(string(out[i+2:]))[0], 64)109 if got, _ := measured(raw); math.Abs(got-want) > 0.2 {110 t.Errorf("meter %.2f LUFS, ffmpeg %.2f", got, want)111 }112}113114func TestGainFor(t *testing.T) {115 for _, c := range []struct{ lufs, target, want float64 }{116 {-14, -24, -10}, {-24, -24, 0}, {-30, -24, 6}, {-45, -24, 12}, {2, -24, -20}, {-60, -24, 0}, {-14, 0, 0},117 {math.NaN(), -24, 0},118 } {119 if got := gainFor(c.lufs, c.target); got != c.want {120 t.Errorf("gainFor(%v, %v) = %v, want %v", c.lufs, c.target, got, c.want)121 }122 }123}124125// levelAll levels sound through a new leveler in 64 KB chunks, as the126// feed does, and returns it with the gains it went through, in dB, each127// 100 ms.128func levelAll(lv *itemLevel, b []byte) ([]byte, []float64) {129 l := newLeveler(lv)130 out := bytes.Clone(b)131 var gains []float64132 for i := 0; i < len(out); i += relayChunk {133 chunk := out[i:min(i+relayChunk, len(out))]134 for j := 0; j < len(chunk); j += subBlock * 4 {135 l.process(chunk[j:min(j+subBlock*4, len(chunk))])136 gains = append(gains, l.gain)137 }138 }139 l.finish()140 return out, gains141}142143// TestLevelerConverges: two videos 12 dB apart come out at the target144// once a few seconds have been measured, without overshooting or145// clipping.146func TestLevelerConverges(t *testing.T) {147 for _, c := range []struct {148 name string149 src func(float64) (float64, float64)150 }{151 {"loud", noise(-20, 1)}, // about -14 LUFS, as YouTube uploads often are152 {"quiet", noise(-32, 2)}, // about -26153 } {154 in := pcm(40, c.src)155 before, _ := measured(in)156 out, gains := levelAll(&itemLevel{Target: -24}, in)157 after, peak := measured(out[len(out)-20*sampleRate*4:]) // the last 20 s158 if math.Abs(after+24) > 1.5 {159 t.Errorf("%s (%.1f LUFS): levelled to %.1f LUFS", c.name, before, after)160 }161 want := gainFor(before, -24)162 for i := 1; i < len(gains); i++ {163 if math.Abs(gains[i]-gains[i-1]) > maxSlew+1e-9 {164 t.Fatalf("%s: gain jumped from %.2f to %.2f dB", c.name, gains[i-1], gains[i])165 }166 if (want > 0 && gains[i] > want+0.5) || (want < 0 && gains[i] < want-0.5) {167 t.Fatalf("%s: gain %.2f dB overshoots %.2f", c.name, gains[i], want)168 }169 }170 if peak > peakCeiling+1e-3 {171 t.Errorf("%s: peak %.3f over the ceiling", c.name, peak)172 }173 t.Logf("%s: %.1f LUFS in, %.1f out; gain %.1f dB after 5 s, %.1f after 10, %.1f at the end", c.name, before, after,174 gains[50], gains[100], gains[len(gains)-1])175 }176}177178// TestLevelerGuardsPeaks: a quiet video with loud clicks is raised, and179// the clicks held under -1 dBFS.180func TestLevelerGuardsPeaks(t *testing.T) {181 quiet := noise(-42, 3)182 in := pcm(30, func(t float64) (float64, float64) {183 l, r := quiet(t)184 if math.Mod(t, 3) < 0.0001 { // a click every three seconds185 return 0.5, -0.5186 }187 return l, r188 })189 out, gains := levelAll(&itemLevel{Target: -24}, in)190 if _, peak := measured(out); peak > peakCeiling+1e-3 {191 t.Errorf("peak %.3f over the ceiling", peak)192 }193 if g := gains[len(gains)-1]; g < 8 {194 t.Errorf("gain %.1f dB; the quiet video should be raised", g)195 }196}197198func TestLevelerRemembers(t *testing.T) {199 mem := &memoryLevels{}200 lv := &itemLevel{Target: -24, Key: "v", Memory: mem}201 in := pcm(30, sine(-14, true))202 levelAll(lv, in)203 l, ok := mem.recall("v")204 if !ok || math.Abs(l.LUFS+14) > 0.1 || l.Seconds < 29 {205 t.Fatalf("remembered %+v, %v", l, ok)206 }207 // The next airing starts at the remembered gain.208 out, gains := levelAll(lv, in[:3*sampleRate*4])209 if after, _ := measured(out); math.Abs(gains[0]+10) > 0.05 || math.Abs(after+24) > 0.3 {210 t.Errorf("repeat starts at %.1f dB, plays at %.1f LUFS", gains[0], after)211 }212 if l2, _ := mem.recall("v"); l2 != l {213 t.Errorf("three seconds changed what was remembered: %+v", l2)214 }215 levelAll(lv, pcm(30, sine(-20, true)))216 if l3, _ := mem.recall("v"); math.Abs(l3.LUFS+16.2) > 0.2 || l3.Seconds < 59 {217 t.Errorf("a second airing at -20: remembered %+v, want energies averaged", l3)218 }219 // Unmeasured YouTube videos start 10 dB down.220 if _, gains := levelAll(&itemLevel{Target: -24, Assumed: ytAssumed}, in[:sampleRate*4]); math.Abs(gains[0]+10) > 0.05 {221 t.Errorf("assumed: %.1f dB", gains[0])222 }223}224225func TestLoudnessFile(t *testing.T) {226 path := filepath.Join(t.TempDir(), loudnessFileName)227 f := &loudnessFile{}228 f.use(path, []string{"a", "b"})229 if _, ok := f.recall("a"); ok {230 t.Fatal("found loudness in a new file")231 }232 f.remember("a", loudness{-20, 30})233 f.use(path, []string{"b"})234 f.remember("b", loudness{-30, 60})235 again := &loudnessFile{}236 again.use(path, []string{"a", "b"})237 if _, ok := again.recall("a"); ok {238 t.Error("a key no longer in use was saved")239 }240 if l, ok := again.recall("b"); !ok || l != (loudness{-30, 60}) || again.count([]string{"a", "b"}) != 1 {241 t.Errorf("after a reload: %+v %v", l, ok)242 }243}244245func BenchmarkLeveler(b *testing.B) {246 chunk := pcm(float64(relayChunk/4)/sampleRate, noise(-20, 4))247 l := newLeveler(&itemLevel{Target: -24})248 b.SetBytes(int64(len(chunk)))249 b.ReportAllocs()250 for b.Loop() {251 l.process(chunk)252 }253}254255// TestLevelingInTheStream plays two clips 12 dB apart through the engine,256// twice: the first time each is metered and remembered, so the second257// time both start at the target, and the stream keeps time across them,258// its lead ahead.259func TestLevelingInTheStream(t *testing.T) {260 ffmpeg, err := exec.LookPath("ffmpeg")261 if err != nil {262 t.Skip("no ffmpeg")263 }264 ffprobe, err := exec.LookPath("ffprobe")265 if err != nil {266 t.Skip("no ffprobe")267 }268 dir := t.TempDir()269 mem := &memoryLevels{}270 var items []item271 for _, c := range []struct{ name, amp string }{{"loud.mp4", "0.5"}, {"quiet.mp4", "0.125"}} {272 path := filepath.Join(dir, c.name)273 cmd := exec.Command(ffmpeg, "-hide_banner", "-loglevel", "error",274 "-f", "lavfi", "-i", "testsrc2=s=320x240:r=25:d=5", "-f", "lavfi", "-i", "anoisesrc=color=pink:amplitude="+c.amp+":d=5",275 "-c:v", "libx264", "-c:a", "aac", "-b:a", "192k", path)276 if out, err := cmd.CombinedOutput(); err != nil {277 t.Fatalf("make %s: %v: %s", c.name, err, out)278 }279 items = append(items, item{Path: path, Frames: 5 * loopFPS, Audio: true, Title: c.name,280 Level: &itemLevel{Target: -24, Key: c.name, Memory: mem}})281 }282 stream := func(name string) string {283 n := 0284 cue := func(time.Time, bool) (item, int64, error) {285 if n == len(items) {286 return item{Slate: true, Frames: 10 * loopFPS}, 0, nil287 }288 n++289 return items[n-1], 0, nil290 }291 ctx, cancel := context.WithTimeout(context.Background(), 10500*time.Millisecond)292 defer cancel()293 var out bytes.Buffer294 if err := playCues(ctx, &out, ffmpeg, "test", cue); err != nil {295 t.Fatal(err)296 }297 ts := filepath.Join(dir, name)298 if err := os.WriteFile(ts, out.Bytes(), 0o644); err != nil {299 t.Fatal(err)300 }301 probe, _ := exec.Command(ffprobe, "-v", "error", "-show_entries", "format=duration", "-of", "default=nw=1:nk=1", ts).Output()302 if d, _ := strconv.ParseFloat(strings.TrimSpace(string(probe)), 64); d < 10.5+lead.Seconds()-2.5 || d > 10.5+lead.Seconds()+0.5 {303 t.Errorf("%s: played %.1fs in 10.5s with a %v lead", name, d, lead)304 }305 return ts306 }307 segment := func(ts string, from float64) float64 {308 raw, err := exec.Command(ffmpeg, "-hide_banner", "-loglevel", "error", "-ss", strconv.FormatFloat(from, 'f', 1, 64), "-t", "3.5",309 "-i", ts, "-vn", "-f", "s16le", "-ar", "48000", "-ac", "2", "-").Output()310 if err != nil {311 t.Fatal(err)312 }313 l, _ := measured(raw)314 return l315 }316 first := stream("first.ts")317 for _, it := range items {318 if l, ok := mem.recall(it.Level.Key); !ok || l.Seconds < settle {319 t.Fatalf("%s not remembered: %+v", it.Title, l)320 }321 }322 loud, _ := mem.recall("loud.mp4")323 quiet, _ := mem.recall("quiet.mp4")324 if d := loud.LUFS - quiet.LUFS; d < 11 || d > 13 {325 t.Errorf("remembered %.1f and %.1f LUFS, want 12 apart", loud.LUFS, quiet.LUFS)326 }327 second := stream("second.ts")328 a, b := segment(second, 1), segment(second, 6)329 t.Logf("first airing: %.1f and %.1f LUFS; second: %.1f and %.1f", segment(first, 1), segment(first, 6), a, b)330 if math.Abs(a-b) > 1.5 || math.Abs(a+24) > 1.5 || math.Abs(b+24) > 1.5 {331 t.Errorf("second airing at %.1f and %.1f LUFS; want both about -24", a, b)332 }333}334335// TestWeatherMusicLeveled: the music is leveled as it plays, starting336// from what it measured last time, and remembered when the viewer leaves.337func TestWeatherMusicLeveled(t *testing.T) {338 ffmpeg, err := exec.LookPath("ffmpeg")339 if err != nil {340 t.Skip("no ffmpeg")341 }342 dir := t.TempDir()343 track := filepath.Join(dir, "a.mp3")344 if out, err := exec.Command(ffmpeg, "-hide_banner", "-loglevel", "error", "-f", "lavfi",345 "-i", "anoisesrc=color=pink:amplitude=0.5:d=3", track).CombinedOutput(); err != nil {346 t.Skipf("no MP3 encoder: %v: %s", err, out)347 }348 wx := &Weather{LocalMusic: dir, FFmpeg: ffmpeg, Loudness: -24}349 tracks := wx.Tracks(context.Background())350 key := strings.Join(tracks, "\x00")351 wx.music.remember(key, loudness{LUFS: -14, Seconds: 600})352 audio, cleanup, err := wx.audioInput(tracks)353 if err != nil {354 t.Fatal(err)355 }356 defer cleanup()357 ctx, cancel := context.WithCancel(context.Background())358 r, err := wx.levelMusic(ctx, audio, key)359 if err != nil {360 t.Fatal(err)361 }362 raw := make([]byte, 5*sampleRate*4) // five seconds, the track looping363 if _, err := io.ReadFull(r, raw); err != nil {364 t.Fatal(err)365 }366 cancel()367 r.Close()368 in := pcmOf(t, ffmpeg, track)369 before, _ := measured(in)370 after, _ := measured(raw)371 if math.Abs(after-(before-10)) > 0.5 {372 t.Errorf("music at %.1f LUFS played at %.1f; want 10 dB down, from what was remembered", before, after)373 }374 deadline := time.Now().Add(5 * time.Second)375 for {376 if l, _ := wx.music.recall(key); l.Seconds > 600 {377 break378 }379 if time.Now().After(deadline) {380 t.Fatal("the music's loudness wasn't remembered")381 }382 time.Sleep(50 * time.Millisecond)383 }384}385386// pcmOf decodes a file's sound to 48 kHz stereo s16le.387func pcmOf(t *testing.T, ffmpeg, path string) []byte {388 t.Helper()389 raw, err := exec.Command(ffmpeg, "-hide_banner", "-loglevel", "error", "-i", path, "-f", "s16le", "-ar", "48000", "-ac", "2", "-").Output()390 if err != nil {391 t.Fatal(err)392 }393 return raw394}395396// TestChannelsLevelOnlyWhatStreams: with leveling on, a folder's videos397// with sound carry how to level them, and nothing is measured until they398// play: the counts only grow as airings are remembered.399func TestChannelsLevelOnlyWhatStreams(t *testing.T) {400 ffmpeg, err := exec.LookPath("ffmpeg")401 if err != nil {402 t.Skip("no ffmpeg")403 }404 ffprobe, err := exec.LookPath("ffprobe")405 if err != nil {406 t.Skip("no ffprobe")407 }408 dir := t.TempDir()409 for name, args := range map[string][]string{410 "a.mp4": {"-f", "lavfi", "-i", "testsrc2=s=320x240:r=25:d=2", "-f", "lavfi", "-i", "sine=d=2", "-c:v", "libx264", "-c:a", "aac"},411 "b.mp4": {"-f", "lavfi", "-i", "testsrc2=s=320x240:r=25:d=2", "-c:v", "libx264"},412 } {413 if out, err := exec.Command(ffmpeg, append(append([]string{"-hide_banner", "-loglevel", "error"}, args...), filepath.Join(dir, name))...).CombinedOutput(); err != nil {414 t.Fatalf("%v: %s", err, out)415 }416 }417 f := &Folder{Num: "1.9", Dir: dir, FFmpeg: ffmpeg, FFprobe: ffprobe, Loudness: -24}418 items := f.list()419 if items[0].Level == nil || !strings.HasPrefix(items[0].Level.Key, "a.mp4|") || items[0].Level.Target != -24 || items[1].Level != nil {420 t.Fatalf("levels: %+v, %+v", items[0].Level, items[1].Level)421 }422 if m, n := f.Leveled(); m != 0 || n != 1 {423 t.Errorf("leveled %d of %d before anything played", m, n)424 }425 if _, err := os.Stat(filepath.Join(dir, loudnessFileName)); !os.IsNotExist(err) {426 t.Error("measured before anything played")427 }428 items[0].Level.Memory.remember(items[0].Level.Key, loudness{LUFS: -20, Seconds: 30})429 if m, n := f.Leveled(); m != 1 || n != 1 {430 t.Errorf("leveled %d of %d after an airing", m, n)431 }432 if _, err := os.Stat(filepath.Join(dir, loudnessFileName)); err != nil {433 t.Errorf("not saved: %v", err)434 }435 if off := (&Folder{Num: "1.9", Dir: dir, FFmpeg: ffmpeg, FFprobe: ffprobe}).list(); off[0].Level != nil {436 t.Error("leveling off still levels")437 }438 y := &YouTube{Loudness: -24}439 if lv := y.level("abc"); lv == nil || lv.Assumed != ytAssumed || lv.Key != "abc" {440 t.Errorf("YouTube level: %+v", lv)441 }442 if (&YouTube{}).level("abc") != nil {443 t.Error("YouTube leveling off still levels")444 }445}