clone

a desktop tv and program guide for over-the-air broadcasts: what's on air, what your tuner gets, live tv, recordings

main
airwaves / internal / stream / subtitles_test.go
subtitles_test.go 7.7K
1package stream23import (4 "fmt"5 "os"6 "path/filepath"7 "regexp"8 "strconv"9 "strings"10 "testing"11 "time"1213 "airwaves/internal/cc"14 "airwaves/internal/tuner"15)1617// tsWithPTS is a one-packet segment whose picture is at pts.18func tsWithPTS(pts int64) []byte {19 p := []byte{0x47, 0x41, 0x00, 0x10, 0, 0, 1, 0xe0, 0, 0, 0x80, 0x80, 5,20 byte(pts>>29&0x0e | 0x21), byte(pts >> 22), byte(pts>>14 | 1), byte(pts >> 7), byte(pts<<1 | 1)}21 for len(p) < 188 {22 p = append(p, 0xff)23 }24 // An audio packet first, as segments may start.25 audio := append([]byte{0x47, 0x41, 0x01, 0x10, 0, 0, 1, 0xc0, 0, 0, 0x80, 0x80, 5, 0x21, 0, 1, 0, 1}, make([]byte, 170)...)26 return append(audio, p...)27}2829type fakeHLS struct {30 t *testing.T31 dir string32 segs []int // sequence numbers listed33 pts map[int]int6434}3536// list writes stream_0.m3u8 listing segments from..to, two seconds each,37// with their files.38func (f *fakeHLS) list(from, to int) {39 var b strings.Builder40 fmt.Fprintf(&b, "#EXTM3U\n#EXT-X-VERSION:6\n#EXT-X-TARGETDURATION:2\n#EXT-X-MEDIA-SEQUENCE:%d\n", from)41 for n := from; n <= to; n++ {42 fmt.Fprintf(&b, "#EXTINF:2.000000,\nseg_0_%05d.ts\n", n)43 name := filepath.Join(f.dir, fmt.Sprintf("seg_0_%05d.ts", n))44 if _, err := os.Stat(name); err != nil {45 if err := os.WriteFile(name, tsWithPTS(f.pts[n]), 0o644); err != nil {46 f.t.Fatal(err)47 }48 }49 }50 if err := os.WriteFile(filepath.Join(f.dir, "stream_0.m3u8"), []byte(b.String()), 0o644); err != nil {51 f.t.Fatal(err)52 }53}5455var timestampMap = regexp.MustCompile(`X-TIMESTAMP-MAP=MPEGTS:(\d+),LOCAL:(\d+):(\d+):(\d+)\.(\d+)`)56var cueTimes = regexp.MustCompile(`(\d+):(\d+):(\d+)\.(\d+) --> `)5758func vttSeconds(m []string) float64 {59 h, _ := strconv.Atoi(m[0])60 mi, _ := strconv.Atoi(m[1])61 s, _ := strconv.Atoi(m[2])62 ms, _ := strconv.Atoi(m[3])63 return float64(h*3600+mi*60+s) + float64(ms)/100064}6566func TestSubtitleSegments(t *testing.T) {67 s := func(sec float64) time.Duration { return time.Duration(sec * float64(time.Second)) }68 dir := t.TempDir()69 // The stream's first picture is two seconds short of the 90 kHz clock70 // wrapping round.71 first := int64(1<<33) - 18000072 f := &fakeHLS{t: t, dir: dir, pts: map[int]int64{}}73 for n := range 20 {74 f.pts[n] = (first + int64(n)*180000) & ptsMask75 }76 var script cc.Script77 script.Set(0, s(1e6), []cc.Cue{78 {Start: s(0.5), End: s(1.5), Text: "In the first"},79 {Start: s(3), End: s(5), Text: "Across two & more"},80 {Start: s(9), End: s(10), Text: "word by word", Words: []cc.Word{{Start: s(9), Text: "word"}, {Start: s(9.4), Text: "by"}, {Start: s(9.6), Text: "word"}}},81 })82 sub := newSubtitler(dir, &script)83 f.list(0, 2)84 if err := sub.update(); err != nil {85 t.Fatal(err)86 }87 f.list(0, 5)88 if err := sub.update(); err != nil {89 t.Fatal(err)90 }91 list, err := os.ReadFile(filepath.Join(dir, subsPlaylist))92 if err != nil {93 t.Fatal(err)94 }95 want := "#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:2\n#EXT-X-MEDIA-SEQUENCE:0\n" +96 strings.Repeat("#EXTINF:2.000000,\nsub_%05d.vtt\n", 6)97 want = fmt.Sprintf(want, 0, 1, 2, 3, 4, 5)98 if string(list) != want {99 t.Errorf("subs.m3u8:\n%s\nwant\n%s", list, want)100 }101 read := func(n int) string {102 b, err := os.ReadFile(filepath.Join(dir, subName(n)))103 if err != nil {104 t.Fatal(err)105 }106 return string(b)107 }108 // Where a player puts a cue: its time, less the segment's local time,109 // plus how far the segment's picture is past the stream's first.110 initPTS := first111 placed := func(vtt string) []float64 {112 m := timestampMap.FindStringSubmatch(vtt)113 if m == nil {114 t.Fatalf("no timestamp map in\n%s", vtt)115 }116 mpegts, _ := strconv.ParseInt(m[1], 10, 64)117 local := vttSeconds(m[2:])118 offset := float64((mpegts-initPTS)&ptsMask) / 90000119 var out []float64120 for _, c := range cueTimes.FindAllStringSubmatch(vtt, -1) {121 out = append(out, vttSeconds(c[1:])-local+offset)122 }123 return out124 }125 // Each segment has the cues showing in it, and those starting within126 // ten seconds after, all placed where they belong.127 for n, want := range map[int][]float64{0: {0.5, 3, 9}, 1: {3, 9}, 2: {3, 9}, 3: {9}, 4: {9}, 5: {}} {128 got := placed(read(n))129 if fmt.Sprint(got) != fmt.Sprint(want) && !(len(got) == 0 && len(want) == 0) {130 t.Errorf("segment %d places cues at %v, want %v:\n%s", n, got, want, read(n))131 }132 }133 // Segment 1 is past the clock's wrap: its picture time is small, and its134 // cues still land where they belong.135 if !strings.Contains(read(1), "MPEGTS:0,LOCAL:00:00:02.000") {136 t.Errorf("segment 1:\n%s", read(1))137 }138 if !strings.Contains(read(1), "Across two &amp; more") || !strings.Contains(read(4), "word <00:00:09.400>by <00:00:09.600>word") {139 t.Errorf("cue text:\n%s\n%s", read(1), read(4))140 }141142 // The window slides: old segments go, after a couple more.143 f.list(4, 9)144 if err := sub.update(); err != nil {145 t.Fatal(err)146 }147 for n, there := range map[int]bool{0: false, 1: false, 2: true, 3: true, 9: true} {148 if _, err := os.Stat(filepath.Join(dir, subName(n))); (err == nil) != there {149 t.Errorf("segment %d there: %v, want %v", n, err == nil, there)150 }151 }152 if list, _ := os.ReadFile(filepath.Join(dir, subsPlaylist)); !strings.Contains(string(list), "#EXT-X-MEDIA-SEQUENCE:4\n#EXTINF:2.000000,\nsub_00004.vtt\n") {153 t.Errorf("subs.m3u8 after sliding:\n%s", list)154 }155}156157func TestMasterPlaylistForSubtitles(t *testing.T) {158 in := tuner.Input{Video: "0:v:0", Audio: []tuner.AudioTrack{{Map: "0:a:0"}}, Captions: true, Subtitles: &cc.Script{}}159 m := masterPlaylist(in)160 for _, want := range []string{161 `#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs",NAME="English",LANGUAGE="en",DEFAULT=NO,AUTOSELECT=NO,FORCED=NO,URI="subs.m3u8"`,162 `SUBTITLES="subs",CLOSED-CAPTIONS=NONE`,163 } {164 if !strings.Contains(m, want) {165 t.Errorf("master lacks %s:\n%s", want, m)166 }167 }168 if strings.Contains(m, "TYPE=CLOSED-CAPTIONS") {169 t.Errorf("captions twice:\n%s", m)170 }171 if got := playlists(in); fmt.Sprint(got) != "[stream_0.m3u8 subs.m3u8]" {172 t.Errorf("playlists %v", got)173 }174 in.VOD = true175 if strings.Contains(masterPlaylist(in), "SUBTITLES") || len(playlists(in)) != 1 {176 t.Error("a recording has no subtitle rendition")177 }178}179180// TestSubtitlesByAudio: a copied picture starts at the key frame before181// where the stream was asked to, the sound there; the captions, timed182// from where it was asked to, come that much after the first picture. The183// subtitles' playlist is of the video's kind, and ends with it.184func TestSubtitlesByAudio(t *testing.T) {185 dir := t.TempDir()186 pes := func(id byte, pts int64) []byte {187 p := []byte{0x47, 0x41, 0x00, 0x10, 0, 0, 1, id, 0, 0, 0x80, 0x80, 5,188 byte(pts>>29&0x0e | 0x21), byte(pts >> 22), byte(pts>>14 | 1), byte(pts >> 7), byte(pts<<1 | 1)}189 return append(p, make([]byte, 188-len(p))...)190 }191 picture := int64(900000)192 seg := append(pes(0xe0, picture), pes(0xc0, picture+180000)...) // the sound two seconds on193 if err := os.WriteFile(filepath.Join(dir, "seg_0_00000.ts"), seg, 0o644); err != nil {194 t.Fatal(err)195 }196 list := "#EXTM3U\n#EXT-X-VERSION:6\n#EXT-X-TARGETDURATION:4\n#EXT-X-PLAYLIST-TYPE:EVENT\n#EXT-X-MEDIA-SEQUENCE:0\n#EXTINF:4.000000,\nseg_0_00000.ts\n#EXT-X-ENDLIST\n"197 if err := os.WriteFile(filepath.Join(dir, "stream_0.m3u8"), []byte(list), 0o644); err != nil {198 t.Fatal(err)199 }200 var script cc.Script201 script.Set(0, time.Hour, []cc.Cue{{Start: time.Second, End: 1500 * time.Millisecond, Text: "one second in"}})202 s := newSubtitler(dir, &script).byAudio()203 if err := s.update(); err != nil {204 t.Fatal(err)205 }206 vtt, _ := os.ReadFile(filepath.Join(dir, subName(0)))207 if !strings.Contains(string(vtt), "00:00:03.000 --> 00:00:03.500\none second in") {208 t.Errorf("segment:\n%s", vtt)209 }210 subs, _ := os.ReadFile(filepath.Join(dir, subsPlaylist))211 if !strings.Contains(string(subs), "#EXT-X-PLAYLIST-TYPE:EVENT\n") || !strings.HasSuffix(string(subs), "#EXT-X-ENDLIST\n") {212 t.Errorf("playlist:\n%s", subs)213 }214}