1package cc23import (4 "cmp"5 "math/bits"6 "slices"7 "strings"8 "time"9)1011// Track is captions as CEA-608 captions on CC1, the way broadcast TV sends12// them. Subtitles go as pop-on captions: each is loaded off screen ahead of13// its time, shown whole at its start and cleared at its end. Captions timed14// word by word (speech recognition's) roll up two lines at a time as live15// TV's do, each word appearing as it's said. Every video frame carries one16// byte pair, so a caption takes a frame per two letters to send.17type Track struct {18 frames []int64 // when each pair is sent, ascending19 pairs [][2]byte20}2122// Padding is the byte pair sent when there is nothing to say.23const Padding = 0x802425// CC1 control codes (the second byte; the first is 0x14), each sent twice26// in a row so a decoder that misses one still acts.27const (28 rcl = 0x20 // resume caption loading: pop-on mode29 ru2 = 0x25 // roll-up captions, two rows30 edm = 0x2c // erase displayed memory31 cr = 0x2d // carriage return: roll up a row32 enm = 0x2e // erase non-displayed memory33 eoc = 0x2f // end of caption: show what was loaded34)3536// NewTrack encodes cues for video at fps frames a second, cue times37// counting from frame 0: rolling up if they time their words, else as38// pop-on captions laid out for the screen. Frame 0 clears the screen, so a39// track can take over from another mid-stream.40func NewTrack(cues []Cue, fps int) *Track {41 t := &Track{}42 t.add(0, twice(nil, control(edm))...)43 if slices.ContainsFunc(cues, func(c Cue) bool { return len(c.Words) > 0 }) {44 t.rollUp(cues, fps)45 } else {46 t.popOn(captions608(cues), fps)47 }48 t.sort()49 return t50}5152// frames converts a time to frames at fps.53func frames(d time.Duration, fps int) int64 {54 return (int64(d)*int64(fps) + int64(time.Second)/2) / int64(time.Second)55}5657// popOn sends captions that fit the screen as pop-on captions.58func (t *Track) popOn(cues []Cue, fps int) {59 frame := func(d time.Duration) int64 { return frames(d, fps) }60 // Shorter gaps between captions aren't blanked, which would flicker.61 minGap := max(int64(fps)/5, 3)62 minShow := int64(fps) / 26364 cursor := int64(2) // the first frame free for loading65 clearAt := int64(-1) // when the caption showing is due off66 for _, c := range cues {67 load := loadCaption(c.Text)68 if len(load) == 0 {69 continue70 }71 n, start := int64(len(load)), max(frame(c.Start), 0)72 blank := clearAt >= 0 && start-clearAt >= minGap73 // Load as late as fits, so a viewer tuning in mid-caption soon sees74 // the next: just before this one shows, and with the caption before75 // to clear first, around that.76 var end int64 // the frame after loading77 switch {78 case !blank:79 at := max(start-n, cursor)80 t.add(at, load...)81 end = at + n82 case start-n >= clearAt+2:83 t.add(clearAt, twice(nil, control(edm))...)84 t.add(start-n, load...)85 end = start86 default:87 // The erase goes in among the load, at its time, but not88 // between a code and its repeat where that would act twice.89 at := max(start-n-2, cursor)90 k := min(max(clearAt-at, 0), n)91 if k > 0 && k < n && load[k-1] == load[k] && load[k][0] < 0x20 && load[k][1] < 0x40 {92 k++93 }94 t.add(at, load[:k]...)95 t.add(at+k, twice(nil, control(edm))...)96 t.add(at+k+2, load[k:]...)97 end = at + n + 298 }99 show := max(start, end)100 t.add(show, twice(nil, control(eoc))...)101 cursor = show + 2102 clearAt = max(frame(c.End), show+minShow, cursor)103 }104 if clearAt >= 0 {105 t.add(clearAt, twice(nil, control(edm))...)106 }107}108109// rollUp sends captions word by word, each as it's said, rolling up two110// rows on the bottom of the screen. A pause of a second clears it.111func (t *Track) rollUp(cues []Cue, fps int) {112 frame := func(d time.Duration) int64 { return frames(d, fps) }113 cursor := int64(2) // the first frame free114 col := -1 // where the next character goes; -1 with the screen clear115 last := int64(-1) // when the last caption ends116 // Of the words said before the track begins, a viewer tuning in117 // mid-sentence gets a line's worth, not a rush of them.118 said, dropped := Columns, map[[2]int]bool{}119 for i := len(cues) - 1; i >= 0; i-- {120 for j := len(cues[i].Words) - 1; j >= 0; j-- {121 if w := cues[i].Words[j]; w.Start <= 0 {122 if said -= len([]rune(w.Text)) + 1; said < 0 {123 dropped[[2]int{i, j}] = true124 }125 }126 }127 }128 for i, c := range cues {129 words := c.Words130 if len(words) == 0 {131 words = spread(c)132 }133 if col >= 0 && frame(c.Start) > last+int64(fps) {134 at := max(last, cursor)135 t.add(at, twice(nil, control(edm))...)136 cursor, col = at+2, -1137 }138 for j, w := range words {139 if dropped[[2]int{i, j}] {140 continue141 }142 var chars []char608143 for _, r := range strings.Join(strings.Fields(fold(w.Text)), " ") {144 if ch, ok := charset[r]; ok {145 chars = append(chars, ch)146 }147 }148 for len(chars) > 0 {149 piece := chars[:min(len(chars), Columns)]150 chars = chars[len(piece):]151 var p [][2]byte152 switch {153 case col < 0:154 p = twice(twice(nil, control(ru2)), pac(15, 0))155 col = 0156 case col+1+len(piece) > Columns:157 p = twice(nil, control(cr))158 col = 0159 default:160 piece = append([]char608{charset[' ']}, piece...)161 }162 p = appendChars(p, piece)163 col += len(piece)164 at := max(frame(w.Start), cursor)165 t.add(at, p...)166 cursor = at + int64(len(p))167 }168 }169 last = max(last, frame(c.End), cursor)170 }171 if col >= 0 {172 t.add(max(last, cursor), twice(nil, control(edm))...)173 }174}175176// spread times a caption's words evenly over most of its time.177func spread(c Cue) []Word {178 fields := strings.Fields(c.Text)179 out := make([]Word, len(fields))180 for i, f := range fields {181 out[i] = Word{Start: c.Start + (c.End-c.Start)*4/5*time.Duration(i)/time.Duration(len(fields)), Text: f}182 }183 return out184}185186// add sends pairs on consecutive frames from frame on.187func (t *Track) add(frame int64, pairs ...[2]byte) {188 for i, p := range pairs {189 t.frames = append(t.frames, frame+int64(i))190 t.pairs = append(t.pairs, [2]byte{parity(p[0]), parity(p[1])})191 }192}193194func (t *Track) sort() {195 idx := make([]int, len(t.frames))196 for i := range idx {197 idx[i] = i198 }199 slices.SortStableFunc(idx, func(a, b int) int { return cmp.Compare(t.frames[a], t.frames[b]) })200 frames, pairs := make([]int64, len(idx)), make([][2]byte, len(idx))201 for i, j := range idx {202 frames[i], pairs[i] = t.frames[j], t.pairs[j]203 }204 t.frames, t.pairs = frames, pairs205}206207// Pair returns the field 1 byte pair, with parity, for a frame: Padding,208// Padding when there's nothing to send.209func (t *Track) Pair(frame int64) (byte, byte) {210 if t == nil {211 return Padding, Padding212 }213 i, ok := slices.BinarySearch(t.frames, frame)214 if !ok {215 return Padding, Padding216 }217 return t.pairs[i][0], t.pairs[i][1]218}219220// control is a CC1 control code.221func control(code byte) [2]byte { return [2]byte{0x14, code} }222223// loadCaption is the pairs that load a caption into non-displayed memory,224// without parity: its lines centered on the bottom rows. It is in225// characters 608 has, as chunk leaves it.226func loadCaption(text string) [][2]byte {227 lines := strings.Split(text, "\n")228 if len(lines) > 4 {229 lines = lines[len(lines)-4:]230 }231 p := twice(nil, control(rcl))232 p = twice(p, control(enm))233 empty := true234 for i, line := range lines {235 var chars []char608236 for _, r := range line {237 if c, ok := charset[r]; ok {238 chars = append(chars, c)239 }240 }241 chars = chars[:min(len(chars), Columns)]242 if len(chars) == 0 {243 continue244 }245 empty = false246 col := (Columns - len(chars)) / 2247 p = twice(p, pac(15-len(lines)+1+i, col))248 if col%4 > 0 {249 p = twice(p, [2]byte{0x17, 0x20 + byte(col%4)}) // tab offset250 }251 p = appendChars(p, chars)252 }253 if empty {254 return nil255 }256 return p257}258259func twice(p [][2]byte, code [2]byte) [][2]byte { return append(p, code, code) }260261// pac is the preamble address code that starts a line, in white, at row262// (1 to 15) and col, rounded down to a multiple of 4 (a tab offset makes263// up the rest).264func pac(row, col int) [2]byte {265 rows := [16][2]byte{266 1: {0x11, 0x40}, 2: {0x11, 0x60}, 3: {0x12, 0x40}, 4: {0x12, 0x60}, 5: {0x15, 0x40},267 6: {0x15, 0x60}, 7: {0x16, 0x40}, 8: {0x16, 0x60}, 9: {0x17, 0x40}, 10: {0x17, 0x60},268 11: {0x10, 0x40}, 12: {0x13, 0x40}, 13: {0x13, 0x60}, 14: {0x14, 0x40}, 15: {0x14, 0x60},269 }270 c := rows[max(1, min(row, 15))]271 c[1] |= 0x10 | byte(col/4)<<1272 return c273}274275// appendChars sends a line's characters: basic ones two to a pair, and276// special and extended ones as their own codes, twice like control codes.277// An extended character follows the basic one decoders without it show,278// which it replaces.279func appendChars(p [][2]byte, chars []char608) [][2]byte {280 var held byte // a basic character waiting for a partner281 basic := func(b byte) {282 if held == 0 {283 held = b284 return285 }286 p = append(p, [2]byte{held, b})287 held = 0288 }289 var last [2]byte290 for _, c := range chars {291 if c.code[1] == 0 {292 basic(c.code[0])293 last = [2]byte{}294 continue295 }296 if c.code == last && c.alt == 0 {297 // A repeat would be taken for the second sending of the last.298 basic(' ')299 }300 if c.alt != 0 {301 basic(c.alt)302 }303 if held != 0 {304 p = append(p, [2]byte{held, 0})305 held = 0306 }307 p = twice(p, c.code)308 last = c.code309 }310 if held != 0 {311 p = append(p, [2]byte{held, 0})312 }313 return p314}315316// parity sets the top bit of b so it has an odd number of ones.317func parity(b byte) byte {318 b &= 0x7f319 if bits.OnesCount8(b)%2 == 0 {320 b |= 0x80321 }322 return b323}324325// char608 is how a character is sent: a basic character alone in code[0],326// else a two-byte code with, for an extended character, alt, the basic327// character it replaces on screen.328type char608 struct {329 code [2]byte330 alt byte331}332333// charset is every character CEA-608 can show, in CC1.334var charset = func() map[rune]char608 {335 m := map[rune]char608{}336 for r := rune(0x20); r < 0x7f; r++ {337 m[r] = char608{code: [2]byte{byte(r)}}338 }339 // The basic set swaps some ASCII for accented letters; the extended340 // sets below have the ASCII back.341 for b, r := range map[byte]rune{342 0x2a: 'á', 0x5c: 'é', 0x5e: 'í', 0x5f: 'ó', 0x60: 'ú', 0x7b: 'ç', 0x7c: '÷', 0x7d: 'Ñ', 0x7e: 'ñ', 0x7f: '█',343 } {344 delete(m, rune(b))345 m[r] = char608{code: [2]byte{b}}346 }347 for i, r := range []rune("®°½¿™¢£♪à\x00èâêîôû") {348 if r != 0 {349 m[r] = char608{code: [2]byte{0x11, 0x30 + byte(i)}}350 }351 }352 // Single quote marks go as apostrophes, leaving out 0x1226 and 0x1229:353 // decoders differ on which is which.354 extended := []struct {355 hi byte356 set, alts string357 }{358 {0x12, "ÁÉÓÚÜü\x00¡*\x00—©℠•“”ÀÂÇÈÊËëÎÏïÔÙùÛ«»", "AEOUUu !- -cs.\"\"AACEEEeIIiOUuU\"\""},359 {0x13, "ÃãÍÌìÒòÕõ{}\\^_|~ÄäÖöߥ¤¦Å娸┌┐└┘", "AaIIiOoOo()/ -:-AaOosY$:AaOo++++"},360 }361 for _, e := range extended {362 alts := []byte(e.alts)363 for i, r := range []rune(e.set) {364 if r != 0 {365 m[r] = char608{code: [2]byte{e.hi, 0x20 + byte(i)}, alt: alts[i]}366 }367 }368 }369 return m370}()