summaryrefslogtreecommitdiff
path: root/server/internal
diff options
context:
space:
mode:
Diffstat (limited to 'server/internal')
-rw-r--r--server/internal/transcript/publish.go46
-rw-r--r--server/internal/transcript/publish_test.go69
-rw-r--r--server/internal/transcript/recurring.go114
-rw-r--r--server/internal/transcript/recurring_test.go207
4 files changed, 436 insertions, 0 deletions
diff --git a/server/internal/transcript/publish.go b/server/internal/transcript/publish.go
index 5d6958c..670a8d5 100644
--- a/server/internal/transcript/publish.go
+++ b/server/internal/transcript/publish.go
@@ -59,3 +59,49 @@ func Publishable(found []Candidate, servedMs, declaredMs int64) ([]Candidate, st
}
return measured, ""
}
+
+// recurringShareLimit is how much of an episode may recur before the result is refused, as a
+// percentage. Thirty-five, the same share the ad scan refuses at and for the same reason: a
+// re-aired episode, a two-parter or a show built around a formulaic segment would otherwise be
+// eaten whole.
+const recurringShareLimit = 35
+
+/*
+PublishableRecurring returns the recurring text fit to skip as the show's own audio.
+
+Separate from [Publishable] because it publishes a different thing. Subtraction measures
+advertising and the phone skips advertising unconditionally; this measures only that a show says
+something every week, which is its sponsor reads and its furniture together, and nothing
+available separates them -- ad copy appears in 5% of the advertising and 4% of the furniture,
+and length and position overlap throughout. So it is published as the show's own audio, which
+the listener's own setting governs, and the stronger claim is left unmade.
+
+Guarded by share of the episode rather than by the length gap, because the episodes this is
+worth most on are the ones with no gap at all: a show that bakes its reads into what it
+publishes declares the length it serves, so the gap is blind to every advertisement in it.
+*/
+func PublishableRecurring(found []Candidate, episodeMs int64) ([]Candidate, string) {
+ recurring := make([]Candidate, 0, len(found))
+ for _, c := range found {
+ if len(c.Signals) == 1 && c.Signals[0] == SignalRecurring {
+ recurring = append(recurring, c)
+ }
+ }
+ if len(recurring) == 0 {
+ return nil, ""
+ }
+ if episodeMs <= 0 {
+ return nil, "the episode's length is unknown, so there is no share to judge this against"
+ }
+
+ var total int64
+ for _, c := range recurring {
+ total += c.EndMs - c.StartMs
+ }
+ if total*100 > episodeMs*recurringShareLimit {
+ return nil, fmt.Sprintf(
+ "%s of a %s episode recurs, which is too much of it to have been understood",
+ clock(total), clock(episodeMs))
+ }
+ return recurring, ""
+}
diff --git a/server/internal/transcript/publish_test.go b/server/internal/transcript/publish_test.go
index edcf9c9..8c98a8f 100644
--- a/server/internal/transcript/publish_test.go
+++ b/server/internal/transcript/publish_test.go
@@ -116,3 +116,72 @@ func TestNothingFoundPublishesNothingAndSaysNothing(t *testing.T) {
t.Errorf("refused %q, but there was nothing to refuse", why)
}
}
+
+/*
+Recurring text is published as the show's own audio, governed by the listener's preference.
+
+It is not advertising as far as anything here can tell -- see the measurement in
+recurring_test.go -- so it is published as what it demonstrably is: text this show says every
+week. The phone skips a show's own audio only if asked, and advertising always, so claiming the
+weaker of the two is the safe mistake.
+
+Guarded by share of the episode rather than by the length gap, because the episodes this is
+worth most on are the ones with no gap at all: a show that bakes its reads into what it
+publishes declares the length it serves, and the gap is blind to every advertisement in it.
+*/
+
+func TestRecurringTextIsPublishableAsTheShowsOwn(t *testing.T) {
+ found := candidates(SignalRecurring, [2]int64{0, 60_000}, [2]int64{600_000, 700_000})
+
+ got, why := PublishableRecurring(found, 3_600_000)
+ if why != "" {
+ t.Fatalf("refused: %s", why)
+ }
+ if len(got) != 2 {
+ t.Errorf("got %d, want 2", len(got))
+ }
+}
+
+/*
+A show that recurs through a third of itself has not been understood.
+
+The same share the ad scan refuses at, and for the same reason: a re-aired episode, a two-parter
+or a show with a formulaic segment would otherwise be eaten whole.
+*/
+func TestTooMuchOfTheEpisodeRecurringIsRefused(t *testing.T) {
+ found := candidates(SignalRecurring, [2]int64{0, 1_500_000})
+
+ got, why := PublishableRecurring(found, 3_600_000)
+ if len(got) != 0 {
+ t.Errorf("published %d covering %d%% of the episode", len(got), 1_500_000*100/3_600_000)
+ }
+ if why == "" {
+ t.Error("said nothing about why")
+ }
+}
+
+// An episode of unknown length cannot be judged by share of itself.
+func TestRecurringTextNeedsTheEpisodeLength(t *testing.T) {
+ found := candidates(SignalRecurring, [2]int64{0, 60_000})
+
+ if got, why := PublishableRecurring(found, 0); len(got) != 0 || why == "" {
+ t.Errorf("got %d breaks, refused %q", len(got), why)
+ }
+}
+
+// Measured advertising is published by Publishable and must not come through here as the show's
+// own audio, which would weaken it from always-skipped to skipped-by-preference.
+func TestMeasuredAdvertisingDoesNotComeThroughAsFurniture(t *testing.T) {
+ found := candidates(SignalDifference, [2]int64{0, 60_000})
+
+ if got, _ := PublishableRecurring(found, 3_600_000); len(got) != 0 {
+ t.Error("measured advertising was republished as the show's own audio")
+ }
+}
+
+func TestNoRecurringTextPublishesNothing(t *testing.T) {
+ got, why := PublishableRecurring(nil, 3_600_000)
+ if len(got) != 0 || why != "" {
+ t.Errorf("got %d breaks, refused %q", len(got), why)
+ }
+}
diff --git a/server/internal/transcript/recurring.go b/server/internal/transcript/recurring.go
new file mode 100644
index 0000000..02fd68a
--- /dev/null
+++ b/server/internal/transcript/recurring.go
@@ -0,0 +1,114 @@
+package transcript
+
+import "strings"
+
+/*
+Finding what a show says in every episode.
+
+The text analogue of the audio scan, and it costs nothing: the server already holds transcripts
+for eight episodes of each show, mirrored within a minute of each other. They carry the same
+advertising campaign and different programmes, so text appearing across several of them is not
+the programme.
+
+It reaches where subtraction cannot. Subtraction needs a second description of one episode --
+the publisher's transcript, or a second fetch weeks later -- and only 18 of 337 mirrored
+episodes have one. This needs nothing but the episodes the server already holds, which is why
+it is worth more than either of the other two: measured against the length gap on the live
+library, Heavyweight 111%, Incels 105%, Ezra Klein 92%, Tangle 83%, against refetching's 21% on
+the same show.
+
+What it finds is "what this show says every week", which is advertising *and* the show's own
+furniture -- its welcome, its break cue, its credits. Those are not the same thing to a
+listener and are not treated as the same thing here; see Furniture.
+*/
+
+// SignalRecurring marks a candidate found because several of a show's episodes say it.
+const SignalRecurring = "recurring"
+
+const (
+ // recurringWords is how long a run must be to count as the same text in two episodes.
+ // Six: long enough that an ordinary sentence of programme does not collide with another
+ // episode's, short enough to survive recognition hearing one word differently.
+ recurringWords = 6
+ // minOtherEpisodes is how many other episodes must say it. Two, because two episodes of a
+ // show share turns of phrase, running jokes and guests' names, and one coincidence should
+ // not become a skip. A campaign runs across the whole batch.
+ minOtherEpisodes = 2
+)
+
+// Recurring reports the stretches of target that at least [minOtherEpisodes] of others also say.
+func Recurring(target []Cue, others [][]Cue) []Candidate {
+ targetWords := tokenise(target)
+ if len(targetWords) < recurringWords || len(others) < minOtherEpisodes {
+ return nil
+ }
+
+ elsewhere := make([]map[string]bool, 0, len(others))
+ for _, other := range others {
+ elsewhere = append(elsewhere, runsOf(tokenise(other), recurringWords))
+ }
+
+ // A word is shared when some run covering it is said by enough other episodes. Marking
+ // every word of a matching run, rather than its first, is what makes the stretches join up
+ // into one break instead of a chain of six-word fragments.
+ shared := make([]bool, len(targetWords))
+ for i := 0; i+recurringWords <= len(targetWords); i++ {
+ run := joinWords(targetWords[i : i+recurringWords])
+ said := 0
+ for _, other := range elsewhere {
+ if other[run] {
+ said++
+ }
+ }
+ if said < minOtherEpisodes {
+ continue
+ }
+ for j := i; j < i+recurringWords; j++ {
+ shared[j] = true
+ }
+ }
+
+ return candidatesFrom(targetWords, shared, SignalRecurring)
+}
+
+// candidatesFrom turns runs of marked words into candidates, dropping anything too brief to be
+// a break -- a line both episodes happen to use is not something to seek past.
+func candidatesFrom(words []token, marked []bool, signal string) []Candidate {
+ var found []Candidate
+ for i := 0; i < len(words); {
+ if !marked[i] {
+ i++
+ continue
+ }
+ start := i
+ for i < len(words) && marked[i] {
+ i++
+ }
+ candidate := Candidate{
+ StartMs: words[start].startMs,
+ EndMs: words[i-1].endMs,
+ Signals: []string{signal},
+ }
+ if candidate.EndMs-candidate.StartMs >= minCandidateMs {
+ found = append(found, candidate)
+ }
+ }
+ return found
+}
+
+// runsOf indexes every run of n words.
+func runsOf(words []token, n int) map[string]bool {
+ runs := make(map[string]bool, len(words))
+ for i := 0; i+n <= len(words); i++ {
+ runs[joinWords(words[i:i+n])] = true
+ }
+ return runs
+}
+
+func joinWords(words []token) string {
+ parts := make([]string, len(words))
+ for i, w := range words {
+ parts[i] = w.word
+ }
+ return strings.Join(parts, " ")
+}
diff --git a/server/internal/transcript/recurring_test.go b/server/internal/transcript/recurring_test.go
new file mode 100644
index 0000000..0de5086
--- /dev/null
+++ b/server/internal/transcript/recurring_test.go
@@ -0,0 +1,207 @@
+package transcript
+
+import "testing"
+
+/*
+Finding what a show says in every episode.
+
+The text analogue of the audio scan, and it costs nothing: the server already holds transcripts
+for eight episodes of each show, mirrored within a minute of each other. They carry the same
+advertising campaign and different programmes, so text appearing across several of them is not
+the programme.
+
+It reaches where subtraction cannot. Subtraction needs a second description of one episode -- a
+publisher's transcript or a second fetch -- and only 18 of 337 mirrored episodes have one. This
+needs nothing but the episodes themselves.
+
+What it finds is "what this show says every week", which is advertising *and* the show's own
+furniture: its welcome, its break cue, its credits. Telling those apart is [Recurring]'s
+caller's problem, not its own -- see Furniture.
+*/
+
+/*
+show builds a programme whose every line is unlike every other show's.
+
+Each word carries the prefix, so no run of six is ever shared by accident. The first version of
+this helper gave every line the same tail -- "which continues for a while with its own
+particular words" -- and the detector dutifully found it in all three episodes, which is correct
+behaviour against an incorrect fixture.
+*/
+func show(prefix string, n int) []string {
+ out := make([]string, 0, n)
+ for i := 0; i < n; i++ {
+ line := prefix + string(rune('a'+i))
+ words := make([]string, 0, 12)
+ for w := 0; w < 12; w++ {
+ words = append(words, line+string(rune('0'+w)))
+ }
+ out = append(out, joinStrings(words))
+ }
+ return out
+}
+
+func joinStrings(parts []string) string {
+ out := ""
+ for i, p := range parts {
+ if i > 0 {
+ out += " "
+ }
+ out += p
+ }
+ return out
+}
+
+var sponsorRead = []string{
+ "This message comes from a mattress company who make mattresses for sleeping",
+ "Go to mattress dot com slash listen and use the promotional code today",
+ "Visit them now because terms and conditions will of course always apply",
+ "They have been making those mattresses for forty long years and counting",
+ "Every mattress is rolled up into a box and delivered to your door",
+}
+
+func TestFindsWhatSeveralEpisodesShare(t *testing.T) {
+ target := words(0, lines(show("alpha", 3), sponsorRead, show("alpha2", 3))...)
+ others := [][]Cue{
+ words(0, lines(show("beta", 3), sponsorRead, show("beta2", 3))...),
+ words(0, lines(show("gamma", 3), sponsorRead, show("gamma2", 3))...),
+ }
+
+ got := Recurring(target, others)
+ if len(got) != 1 {
+ t.Fatalf("got %d candidates, want 1: %+v", len(got), got)
+ }
+ if got[0].StartMs != 3*cueMs || got[0].EndMs != 8*cueMs {
+ t.Errorf("got %d-%d, want the five shared lines", got[0].StartMs, got[0].EndMs)
+ }
+}
+
+/*
+One other episode is not a pattern.
+
+Two episodes of a show can share a turn of phrase, a guest's name, a running joke. A campaign
+runs across the batch, so it turns up in several -- and requiring several is what keeps a
+coincidence from becoming a skip.
+*/
+func TestSomethingInOnlyOneOtherEpisodeIsNotRecurring(t *testing.T) {
+ target := words(0, lines(show("alpha", 3), sponsorRead, show("alpha2", 3))...)
+ others := [][]Cue{
+ words(0, lines(show("beta", 3), sponsorRead, show("beta2", 3))...),
+ words(0, lines(show("gamma", 3), show("gamma2", 3))...),
+ }
+
+ if got := Recurring(target, others); len(got) != 0 {
+ t.Errorf("got %+v, want nothing -- one other episode is a coincidence", got)
+ }
+}
+
+// The programme is what differs, and none of it may be reported.
+func TestTheProgrammeIsNotRecurring(t *testing.T) {
+ target := words(0, show("alpha", 8)...)
+ others := [][]Cue{
+ words(0, show("beta", 8)...),
+ words(0, show("gamma", 8)...),
+ }
+
+ if got := Recurring(target, others); len(got) != 0 {
+ t.Errorf("got %+v, want nothing", got)
+ }
+}
+
+// A phrase both episodes happen to use is not a break, and skipping one would clip the show.
+func TestAPassingPhraseIsTooShortToBeABreak(t *testing.T) {
+ shared := []string{"and we will be right back after this"}
+ target := words(0, lines(show("alpha", 4), shared, show("alpha2", 4))...)
+ others := [][]Cue{
+ words(0, lines(show("beta", 4), shared, show("beta2", 4))...),
+ words(0, lines(show("gamma", 4), shared, show("gamma2", 4))...),
+ }
+
+ if got := Recurring(target, others); len(got) != 0 {
+ t.Errorf("got %+v, want nothing -- one line is not a break", got)
+ }
+}
+
+func TestTwoRecurringStretchesStaySeparate(t *testing.T) {
+ second := []string{
+ "Support for this programme comes from a razor company with five sharp blades",
+ "Visit razors dot com slash listen today where terms and conditions apply",
+ "They will send you a handle and a year of replacement cartridges",
+ "And a further word from a company that sells boxes for other things",
+ "Those boxes are cardboard and they fold flat which is awfully convenient",
+ }
+ target := words(0, lines(show("alpha", 3), sponsorRead, show("alpha2", 3), second)...)
+ others := [][]Cue{
+ words(0, lines(show("beta", 3), sponsorRead, show("beta2", 3), second)...),
+ words(0, lines(show("gamma", 3), sponsorRead, show("gamma2", 3), second)...),
+ }
+
+ got := Recurring(target, others)
+ if len(got) != 2 {
+ t.Fatalf("got %d candidates, want 2: %+v", len(got), got)
+ }
+ if got[0].EndMs >= got[1].StartMs {
+ t.Errorf("the two overlap: %+v", got)
+ }
+}
+
+func TestNothingToCompareAgainstFindsNothing(t *testing.T) {
+ target := words(0, show("alpha", 8)...)
+ if got := Recurring(target, nil); len(got) != 0 {
+ t.Errorf("got %+v", got)
+ }
+ if got := Recurring(nil, [][]Cue{words(0, show("beta", 8)...)}); len(got) != 0 {
+ t.Errorf("got %+v", got)
+ }
+}
+
+// Same shape as every other candidate, so one episode's breaks can come from either detector
+// and be read the same way.
+func TestACandidateSaysItRecurs(t *testing.T) {
+ target := words(0, lines(show("alpha", 3), sponsorRead, show("alpha2", 3))...)
+ others := [][]Cue{
+ words(0, lines(show("beta", 3), sponsorRead, show("beta2", 3))...),
+ words(0, lines(show("gamma", 3), sponsorRead, show("gamma2", 3))...),
+ }
+
+ got := Recurring(target, others)
+ if len(got) != 1 || len(got[0].Signals) != 1 || got[0].Signals[0] != SignalRecurring {
+ t.Errorf("got %+v, want one marked %q", got, SignalRecurring)
+ }
+}
+
+/*
+Recurring text is published as the show's own audio, never as advertising.
+
+An honest limit, measured rather than assumed. A show's recurring text is both its sponsor reads
+and its furniture -- the welcome, the break cue, the credits -- and telling those apart would
+need a signal that separates them. None does. Scored against the 404 recurring stretches in
+episodes that also hold a publisher's master, which says which is which:
+
+ ad copy markers 5% of the advertising, 4% of the furniture
+ length mean 51s against 29s, overlapping throughout
+ position ads 62 open / 176 mid / 41 close
+ furniture 12 open / 61 mid / 52 close
+
+A first attempt using ad copy agreed with the master 33% of the time, and called 263
+advertisements furniture.
+
+So the detector claims only what it measured: this is text the show says every week. That is
+exactly what KindCue means, and it leaves the listener's own setting in charge -- furniture is
+skipped by preference, advertising unconditionally, and claiming the stronger of the two on a
+coin flip would be the worse mistake.
+*/
+func TestRecurringTextIsTheShowsOwnAudioAndNotAdvertising(t *testing.T) {
+ target := words(0, lines(show("alpha", 3), sponsorRead, show("alpha2", 3))...)
+ others := [][]Cue{
+ words(0, lines(show("beta", 3), sponsorRead, show("beta2", 3))...),
+ words(0, lines(show("gamma", 3), sponsorRead, show("gamma2", 3))...),
+ }
+
+ got := Recurring(target, others)
+ if len(got) != 1 {
+ t.Fatalf("got %+v", got)
+ }
+ if got[0].Signals[0] == SignalDifference {
+ t.Error("recurring text must not claim to be measured advertising")
+ }
+}