// 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 -- , , <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 }