package transcript import ( "regexp" "strings" ) /* Pulling a candidate's edges back off the programme. Subtraction answers "not in the master", which is a superset of "advertising". Measured across the eighteen episodes holding a master, candidates come to a mean of exactly 100% of what the length gap implies, and individually overshoot by up to 10%. Reading their edges shows two causes. The first is the programme leaking in: Darknet Diaries' "NSL" opens a break on "I'm Jack Resider." and closes one on "So Nick and ACLU, we're going to challenge", and Incels opens two on "let's stop here for a...". Those lines failed to anchor only because recognition misheard a word near them, and skipping them is the harm this whole thing exists to avoid. They are in the master, so they can be found there and given back. The second is whisper's own non-speech markers and a title card -- "[MUSIC PLAYING]", "(upbeat music)", "Today's episode, Tamara." Those are genuinely absent from a publisher's written transcript, so nothing here can tell them from an advertisement. They are left alone: they sit against the break anyway, and a rule invented to catch them would be a guess. */ const ( // trimRunWords is how many consecutive words of a line must appear in the master before it // is treated as the programme. An advertisement's words are not in the master at all -- // that is what made it a candidate -- so what this length really decides is how badly a // line may have been misheard and still be recognised as the programme. // // Measured at four, five and six across the eighteen episodes holding a master. Five and // six give identical results, and four differs from them by one point on one episode, // which is noise on a sample this size. Four is kept because it is the only one of the // three that can still recognise a short line with a mistake in the middle of it -- an // eight-word line with a dented fifth word has no clean five-word run anywhere, and "So // Nick and ACLU, we're going to challenge" is eight words. Erring towards trimming also // errs towards smaller candidates, which is the safe direction: a skip into the programme // is worse than a missed advertisement. trimRunWords = 4 // shortLineWords is where a line is short enough that requiring a full-length run would // mean requiring nearly all of it to be heard correctly. shortLineWords = 5 // trimMinWords is the shortest line this can judge. A two-word line gives nothing to look // for, so it is kept rather than guessed at, which is what leaves the music markers alone. trimMinWords = 3 ) // trimmed pulls each candidate's edges in off any line that the master also contains. // // Only from the ends, and only while the lines keep matching: a line inside the break that // happens to echo the programme is not a reason to split an advertisement in two. // // Part of [Inserted] rather than a step a caller has to remember, because a caller that forgot // it would publish a skip over the last sentence of the programme. func trimmed(found []Candidate, master, served []Cue) []Candidate { if len(found) == 0 { return nil } runs := runsIn(master) out := make([]Candidate, 0, len(found)) for _, candidate := range found { inside := cuesWithin(served, candidate) first, last := 0, len(inside)-1 for first <= last && trimmable(inside[first], runs) { first++ } for last > first && trimmable(inside[last], runs) { last-- } if first > last { // Every line of it is in the master, so none of it was an advertisement. continue } trimmed := Candidate{ StartMs: inside[first].StartMs, EndMs: inside[last].EndMs, Signals: candidate.Signals, } if trimmed.EndMs-trimmed.StartMs < minCandidateMs { continue } out = append(out, trimmed) } return out } // annotation matches a line that is entirely one of whisper's non-speech notes -- "[music]", // "(upbeat music)", "(audience laughing)". 312 of 56,306 recognised lines are one of these. // // They are not speech, so a publisher's written transcript never contains them and they can // never anchor -- which leaves them sitting at the front of a break, widening it. Unlike a // title card, which this deliberately leaves alone, an annotation says what it is by its shape, // so it can be told from an advertisement without guessing. var annotation = regexp.MustCompile(`^\s*(?:[\[(][^\])]{0,40}[\])]|[\x{266a}\x{266b}\x{2669}\x{266c}\s]+)\s*$`) // inMaster reports whether a line's words appear in the master. // trimmable reports whether a line at the edge of a break belongs to the break. func trimmable(cue Cue, runs map[string]bool) bool { return annotation.MatchString(cue.Text) || inMaster(cue, runs) } func inMaster(cue Cue, runs map[string]bool) bool { words := strings.FieldsFunc(strings.ToLower(cue.Text), notWord) if len(words) < trimMinWords { return false } // A short line is judged on a shorter run. Four words out of four means one misheard word // anywhere in it hides the whole line -- which is how "I'm Jack Rhysider" came back as // "I'm Jack Resider" and opened three Darknet Diaries breaks on the host's own sentence. // Three of "i m jack resider" still finds "i m jack" in the master. size := trimRunWords if len(words) <= shortLineWords { size = trimMinWords } if len(words) < size { size = len(words) } for i := 0; i+size <= len(words); i++ { if runs[strings.Join(words[i:i+size], " ")] { return true } } return false } // runsIn is every run of words in the master that a line could be recognised by, at both the // full length and the shorter one a brief line is judged on. func runsIn(master []Cue) map[string]bool { words := tokenise(master) runs := make(map[string]bool, len(words)*2) for size := trimMinWords; size <= trimRunWords; size++ { for i := 0; i+size <= len(words); i++ { parts := make([]string, size) for j := 0; j < size; j++ { parts[j] = words[i+j].word } runs[strings.Join(parts, " ")] = true } } return runs } func cuesWithin(cues []Cue, candidate Candidate) []Cue { var out []Cue for _, cue := range cues { if cue.StartMs >= candidate.StartMs && cue.EndMs <= candidate.EndMs { out = append(out, cue) } } return out }