summaryrefslogtreecommitdiff
path: root/server/internal/transcript/parse.go
blob: e5c39cb7d4110ffbb87ccdd0feffc01448d64293 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
// Package transcript reads an episode's words with the times they were said at, from whatever
// shape the words arrived in.
//
// Two sources, and they are not interchangeable. A publisher's own transcript is exact and free
// but describes the publisher's master -- which for a show using dynamic ad insertion is the
// ad-free programme, minutes shorter than the copy the server mirrored. Speech recognition over
// the mirrored bytes is slower and approximate but describes the audio the phone will actually
// play. [Fits] is what decides whether a published transcript may be used for a given file.
package transcript

import (
	"fmt"
	"regexp"
	"strconv"
	"strings"
)

// Cue is a run of speech and when it was said, in milliseconds from the start of the file.
type Cue struct {
	StartMs int64  `json:"startMs"`
	EndMs   int64  `json:"endMs"`
	Text    string `json:"text"`
}

// Parse reads cues from a published transcript of the declared MIME type.
//
// The types in the wild disagree with each other about the same format: application/srt and
// application/x-subrip are both SRT. text/plain, text/html and application/json carry no
// timestamps in any publisher's usage seen here, so they are refused rather than stored -- a
// transcript with no times can neither be seeked to nor used to place a break, and accepting
// one would mean the episode never gets another attempt.
func Parse(mimeType, body string) ([]Cue, error) {
	var (
		cues []Cue
		err  error
	)
	switch normaliseType(mimeType) {
	case "application/srt", "application/x-subrip", "text/srt":
		cues, err = ParseSRT(body)
	case "text/vtt", "text/webvtt":
		cues, err = ParseVTT(body)
	default:
		return nil, fmt.Errorf("transcript: %q carries no timestamps", mimeType)
	}
	if err != nil {
		return nil, err
	}
	if len(cues) == 0 {
		return nil, fmt.Errorf("transcript: %q parsed to no cues", mimeType)
	}
	return cues, nil
}

func normaliseType(mimeType string) string {
	t := strings.ToLower(strings.TrimSpace(mimeType))
	if i := strings.IndexByte(t, ';'); i >= 0 {
		t = strings.TrimSpace(t[:i])
	}
	return t
}

// timing matches both shapes of cue timing line: SRT's comma and WebVTT's full stop, with the
// hours field optional as WebVTT allows.
var timing = regexp.MustCompile(
	`^(?:(\d+):)?(\d{1,2}):(\d{2})[.,](\d{1,3})\s*-->\s*(?:(\d+):)?(\d{1,2}):(\d{2})[.,](\d{1,3})`,
)

// markup strips WebVTT's cue spans -- <v Speaker>, <i>, <00:00:01.000> -- which are there to be
// displayed, not to be read as something somebody said.
var markup = regexp.MustCompile(`</?[^>]*>`)

// ParseSRT reads SubRip cues. The cue numbers are ignored: they are an index, and a publisher
// that misnumbers them has still said when the words were spoken.
func ParseSRT(body string) ([]Cue, error) { return parseCues(body) }

// ParseVTT reads WebVTT cues, skipping the header, NOTE and STYLE blocks and cue identifiers.
//
// Everything that is not a timing line is either furniture or the text of the cue that a timing
// line just opened, which is why one reader serves both formats.
func ParseVTT(body string) ([]Cue, error) { return parseCues(body) }

func parseCues(body string) ([]Cue, error) {
	var (
		cues    []Cue
		current *Cue
		text    []string
	)
	flush := func() {
		if current == nil {
			return
		}
		current.Text = strings.TrimSpace(strings.Join(text, " "))
		if current.Text != "" {
			cues = append(cues, *current)
		}
		current, text = nil, nil
	}

	for _, line := range strings.Split(strings.ReplaceAll(body, "\r\n", "\n"), "\n") {
		trimmed := strings.TrimSpace(line)
		if m := timing.FindStringSubmatch(trimmed); m != nil {
			flush()
			current = &Cue{StartMs: millis(m[1], m[2], m[3], m[4]), EndMs: millis(m[5], m[6], m[7], m[8])}
			continue
		}
		if trimmed == "" {
			flush()
			continue
		}
		if current == nil {
			// A cue number, a cue identifier, the WEBVTT header, or a NOTE or STYLE block --
			// all of it precedes a timing line rather than following one.
			continue
		}
		text = append(text, strings.TrimSpace(markup.ReplaceAllString(trimmed, "")))
	}
	flush()
	return cues, nil
}

func millis(hours, minutes, seconds, fraction string) int64 {
	h, _ := strconv.ParseInt(emptyAsZero(hours), 10, 64)
	m, _ := strconv.ParseInt(minutes, 10, 64)
	s, _ := strconv.ParseInt(seconds, 10, 64)
	// A fraction of fewer than three digits is tenths or hundredths, not milliseconds.
	f, _ := strconv.ParseInt(fraction, 10, 64)
	for i := len(fraction); i < 3; i++ {
		f *= 10
	}
	return ((h*60+m)*60+s)*1000 + f
}

func emptyAsZero(s string) string {
	if s == "" {
		return "0"
	}
	return s
}