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 / cc / cue.go
cue.go 8.4K
1// Package cc makes closed captions: it reads SRT, WebVTT and YouTube's2// json3 subtitles, encodes them as CEA-608 captions (what a TV's CC button3// turns on) and adds those to the H.264 picture of an MPEG-TS stream as it4// is written, and keeps them as text for players that show WebVTT.5package cc67import (8 "bytes"9 "cmp"10 "encoding/json"11 "errors"12 "fmt"13 "html"14 "regexp"15 "slices"16 "strconv"17 "strings"18 "time"19)2021// Cue is a caption and when it shows.22type Cue struct {23 Start time.Duration24 End time.Duration25 // Text is the caption's lines, separated by "\n", as its source broke26 // them.27 Text string28 // Words, when the source times each word (YouTube's automatic29 // captions), are the words of the caption's last line and when each is30 // spoken; that line of Text is them with spaces between.31 Words []Word32}3334// Word is a word of a caption and when it's spoken.35type Word struct {36 Start time.Duration37 Text string38}3940const (41 // Columns and Rows bound a CEA-608 caption: its screen is 32 columns,42 // and pop-on captions rarely run to more than two rows.43 Columns = 3244 Rows = 245)4647var (48 timing = regexp.MustCompile(`^\s*((?:\d+:)?\d{1,2}:\d{1,2}(?:[,.]\d+)?)\s*-->\s*((?:\d+:)?\d{1,2}:\d{1,2}(?:[,.]\d+)?)`)49 number = regexp.MustCompile(`^\s*\d+\s*$`)50 // markup is HTML-style tags (<i>, <font color=...>, WebVTT's <c.x>,51 // <v Name> and <00:00:01.000>) and ASS overrides ({\an8}).52 markup = regexp.MustCompile(`<[^>\n]*>|\{\\[^}\n]*\}`)53 assBreaks = strings.NewReplacer(`\N`, "\n", `\n`, "\n", `\h`, " ",54 "¶", "♪") // a music note, as subtitles made from broadcast captions misname it55)5657// ErrNoCues is returned for a file with no timed captions in it.58var ErrNoCues = errors.New("cc: no captions found")5960// Parse reads SRT, WebVTT or YouTube's json3 subtitles into cues in time61// order, without styling.62func Parse(b []byte) ([]Cue, error) {63 b = bytes.TrimPrefix(b, []byte("\xef\xbb\xbf"))64 if t := bytes.TrimSpace(b); len(t) > 0 && t[0] == '{' {65 return parseJSON3(t)66 }67 s := string(b)68 s = strings.ReplaceAll(s, "\r\n", "\n")69 s = strings.ReplaceAll(s, "\r", "\n")70 vtt := strings.HasPrefix(s, "WEBVTT")71 lines := strings.Split(s, "\n")72 var cues []Cue73 found := false74 for i := 0; i < len(lines); i++ {75 m := timing.FindStringSubmatch(lines[i])76 if m == nil {77 continue78 }79 found = true80 start, err1 := parseTime(m[1])81 end, err2 := parseTime(m[2])82 var text []string83 // WebVTT ends a caption at an empty line; YouTube's have lines of a84 // space within them.85 for i+1 < len(lines) && !blank(lines[i+1], vtt) && !timing.MatchString(lines[i+1]) {86 // An SRT missing the blank line before the next cue's number.87 if number.MatchString(lines[i+1]) && i+2 < len(lines) && timing.MatchString(lines[i+2]) {88 break89 }90 i++91 text = append(text, lines[i])92 }93 if err1 != nil || err2 != nil || end <= start {94 continue95 }96 if t := clean(text); len(t) > 0 {97 cues = append(cues, Cue{Start: start, End: end, Text: strings.Join(t, "\n")})98 }99 }100 if !found {101 return nil, ErrNoCues102 }103 slices.SortStableFunc(cues, func(a, b Cue) int { return cmp.Compare(a.Start, b.Start) })104 return unroll(cues), nil105}106107// parseJSON3 reads YouTube's json3 subtitles. Those YouTube makes by108// speech recognition time each word, and come as lines that show two at a109// time, each until the line after next has begun.110func parseJSON3(b []byte) ([]Cue, error) {111 var doc struct {112 Events []struct {113 Start int64 `json:"tStartMs"`114 Duration int64 `json:"dDurationMs"`115 Append int `json:"aAppend"`116 Segs []struct {117 Text string `json:"utf8"`118 Offset *int64 `json:"tOffsetMs"`119 } `json:"segs"`120 } `json:"events"`121 }122 if err := json.Unmarshal(b, &doc); err != nil {123 return nil, fmt.Errorf("cc: json3: %w", err)124 }125 if doc.Events == nil {126 return nil, ErrNoCues127 }128 timed := false129 for _, e := range doc.Events {130 for _, s := range e.Segs {131 timed = timed || s.Offset != nil132 }133 }134 ms := func(n int64) time.Duration { return time.Duration(n) * time.Millisecond }135 var cues []Cue136 for _, e := range doc.Events {137 if len(e.Segs) == 0 || e.Append != 0 || e.Duration <= 0 {138 continue // windows, and the line breaks of rolling captions139 }140 c := Cue{Start: ms(e.Start), End: ms(e.Start + e.Duration)}141 if timed {142 for _, s := range e.Segs {143 at := c.Start144 if s.Offset != nil {145 at += ms(*s.Offset)146 }147 for w := range strings.FieldsSeq(html.UnescapeString(s.Text)) {148 c.Words = append(c.Words, Word{Start: at, Text: w})149 }150 }151 var words []string152 for _, w := range c.Words {153 words = append(words, w.Text)154 }155 c.Text = strings.Join(words, " ")156 } else {157 var text strings.Builder158 for _, s := range e.Segs {159 text.WriteString(s.Text)160 }161 c.Text = strings.Join(clean(strings.Split(text.String(), "\n")), "\n")162 }163 if c.Text != "" {164 cues = append(cues, c)165 }166 }167 slices.SortStableFunc(cues, func(a, b Cue) int { return cmp.Compare(a.Start, b.Start) })168 return cues, nil169}170171func blank(line string, vtt bool) bool {172 if vtt {173 return line == ""174 }175 return strings.TrimSpace(line) == ""176}177178// parseTime reads "01:02:03,456", "01:02:03.456" or "02:03.456".179func parseTime(s string) (time.Duration, error) {180 parts := strings.Split(s, ":")181 secs, frac, _ := strings.Cut(strings.ReplaceAll(parts[len(parts)-1], ",", "."), ".")182 var d time.Duration183 for _, p := range append(parts[:len(parts)-1], secs) {184 n, err := strconv.Atoi(p)185 if err != nil {186 return 0, fmt.Errorf("cc: bad time %q", s)187 }188 d = d*60 + time.Duration(n)189 }190 d *= time.Second191 if frac != "" {192 frac = (frac + "000000000")[:9]193 ns, err := strconv.Atoi(frac)194 if err != nil {195 return 0, fmt.Errorf("cc: bad time %q", s)196 }197 d += time.Duration(ns)198 }199 return d, nil200}201202// clean strips a caption's styling and returns its lines.203func clean(lines []string) []string {204 s := assBreaks.Replace(strings.Join(lines, "\n"))205 s = html.UnescapeString(markup.ReplaceAllString(s, ""))206 var out []string207 for l := range strings.SplitSeq(s, "\n") {208 if l = strings.Join(strings.Fields(l), " "); l != "" {209 out = append(out, l)210 }211 }212 return out213}214215// unroll turns rolling captions, where each caption repeats the last216// one's bottom lines above new ones (YouTube's automatic captions, and217// subtitles made from broadcast roll-up captions), into captions of new218// lines only. Blips between captions go too.219func unroll(cues []Cue) []Cue {220 var out []Cue221 var prev []string // the last caption's lines, as it came222 var prevEnd time.Duration223 for _, c := range cues {224 if c.End-c.Start < 50*time.Millisecond {225 continue226 }227 lines := strings.Split(c.Text, "\n")228 kept := lines229 if len(out) > 0 && c.Start-prevEnd < 100*time.Millisecond {230 for k := min(len(lines)-1, len(prev)); k > 0; k-- {231 if slices.Equal(lines[:k], prev[len(prev)-k:]) {232 kept = lines[k:]233 break234 }235 }236 }237 prev, prevEnd = lines, c.End238 c.Text = strings.Join(kept, "\n")239 out = append(out, c)240 }241 return out242}243244// FormatSRT writes cues as SRT.245func FormatSRT(cues []Cue) []byte {246 var b bytes.Buffer247 for i, c := range cues {248 fmt.Fprintf(&b, "%d\n%s --> %s\n%s\n\n", i+1, srtTime(c.Start), srtTime(c.End), c.Text)249 }250 return b.Bytes()251}252253func srtTime(d time.Duration) string {254 d = max(d, 0)255 ms := d.Milliseconds()256 return fmt.Sprintf("%02d:%02d:%02d,%03d", ms/3600000, ms/60000%60, ms/1000%60, ms%1000)257}258259// Shift moves cues by d, dropping those that end up wholly before zero and260// clipping those that straddle it.261func Shift(cues []Cue, d time.Duration) []Cue {262 var out []Cue263 for _, c := range cues {264 c.Start += d265 c.End += d266 if c.End <= 0 {267 continue268 }269 c.Start = max(c.Start, 0)270 if c.Words != nil {271 words := make([]Word, len(c.Words))272 for i, w := range c.Words {273 words[i] = Word{Start: max(w.Start+d, 0), Text: w.Text}274 }275 c.Words = words276 }277 out = append(out, c)278 }279 return out280}281282// English reports whether lang, an ISO 639 code or a name as files and283// servers label tracks ("eng", "en-US", "English"), is English.284func English(lang string) bool {285 l := strings.ToLower(strings.TrimSpace(lang))286 if i := strings.IndexAny(l, "-_"); i >= 0 {287 l = l[:i]288 }289 return l == "en" || l == "eng" || l == "english"290}291292// Foreign reports whether lang names a language other than English. An293// unlabeled or undetermined language isn't foreign: most such tracks are294// English.295func Foreign(lang string) bool {296 switch strings.ToLower(strings.TrimSpace(lang)) {297 case "", "und", "unknown", "mul", "mis", "zxx", "qaa":298 return false299 }300 return !English(lang)301}