summaryrefslogtreecommitdiff
path: root/server/internal/detect/recurring.go
blob: c517b2c8532d7dde60b3c492868047f980039c62 (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
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
package detect

// Clip is a decoded window of one episode's audio, and where that window sits in the episode.
type Clip struct {
	EpisodeID  string
	Samples    []float64
	SampleRate int
	StartMs    int64
}

// Occurrence is where a recurring segment was found in one episode's clip.
type Occurrence struct {
	EpisodeID string
	Position  int // samples into the clip
	Score     float64
}

// Recurring is a segment found in enough episodes to be trusted, and where it sits in each.
type Recurring struct {
	Reference   []float64
	Occurrences []Occurrence
}

// Tuning is what the search is allowed to cost, plus the score a match must clear.
//
// MinScore and the confirmation threshold are safety mechanisms, not performance knobs: the
// threshold is the only thing standing between "the show's intro" and "an ad that happened to
// be inserted into two episodes fetched minutes apart". Reachable for tests, not for operators.
type Tuning struct {
	// CoarseFactor shrinks both clips before ranking candidate positions. 1 searches at full
	// resolution, which is kept reachable so the fast path can be compared against it.
	CoarseFactor int
	// FineCandidates is how many ranked positions get verified at full resolution. Several,
	// for two reasons: decimation can reorder near-equal peaks, and one strongly-recurring
	// segment that is not the one wanted must not be able to crowd the shortlist.
	FineCandidates int
	// MinScore is the correlation a match must exceed to count as the same audio.
	MinScore float64
}

// DefaultTuning is the production configuration, matching the values validated on real shows.
func DefaultTuning() Tuning {
	return Tuning{CoarseFactor: 8, FineCandidates: 5, MinScore: 0.6}
}

// minCoarseWindow is the point below which a decimated window stops carrying enough signal to
// rank positions by, so the search runs at full resolution instead.
const minCoarseWindow = 512

// candidateStepDivisor makes the search step a quarter of the window.
//
// A step of a whole window would almost always straddle a real embedded segment rather than
// land on it: any embed position that is not a multiple of the window length gets split across
// two candidate windows, each only partly the real signal, and often too weak to clear
// MinScore.
const candidateStepDivisor = 4

// DetectRecurring finds a referenceLen-sample segment present in at least confirmThreshold of
// the clips, and reports where it sits in each clip that has it. Nil when nothing recurs widely
// enough to trust.
//
// This is the whole reason for doing detection on a server. On the phone only one episode's clip
// could be held at a time, so the same question had to be answered by a rolling state machine
// that carried a "pending" clip between runs, re-decoded it, accumulated a confirmation count
// over weeks, and lost everything whenever retention reclaimed the pending episode's file.
// Holding every clip at once replaces all of that with a single pass: the threshold is evaluated
// rather than accumulated.
//
// Costs one coarse window search plus FineCandidates*len(clips) full-resolution correlations --
// not a correlation per window per clip, which would be hundreds of times more work for the
// same answer.
func DetectRecurring(clips []Clip, referenceLen, confirmThreshold int, t Tuning) *Recurring {
	if len(clips) < confirmThreshold || confirmThreshold < 1 || referenceLen <= 0 {
		return nil
	}

	pivot := clips[0]
	win := referenceLen
	for _, c := range clips {
		if len(c.Samples) < win {
			return nil
		}
	}

	// Rank candidate positions in the pivot by how well they match the next clip. Cheap, and
	// only a shortlist -- which of them is real is decided below, against every clip.
	ranked := rankPositions(pivot.Samples, clips[1].Samples, win, t)

	var best *Recurring
	for _, position := range ranked {
		start := clampStart(position, len(pivot.Samples), win)
		reference := pivot.Samples[start : start+win]

		occurrences := locateInAll(reference, clips, t.MinScore)
		if len(occurrences) < confirmThreshold {
			continue
		}
		// Most support wins, not first found. A rotating ad shared by two episodes must lose to
		// an intro shared by all of them -- picking the first candidate over MinScore is how a
		// skip feature starts cutting advertising it happens to have seen twice and calling it
		// the theme tune.
		if best == nil || len(occurrences) > len(best.Occurrences) {
			best = &Recurring{
				Reference:   append([]float64(nil), reference...),
				Occurrences: occurrences,
			}
		}
	}
	return best
}

// rankPositions returns candidate window starts in a, best-matching first, in a's own
// resolution. No score threshold is applied: at a coarse resolution these scores are not the
// ones detection is allowed to decide on, so rejecting here would reject on a number the
// full-resolution pass is about to compute properly.
func rankPositions(a, b []float64, win int, t Tuning) []int {
	factor := coarseFactor(win, t.CoarseFactor)
	searchA, searchB, searchWin := a, b, win
	if factor > 1 {
		searchA, searchB, searchWin = Decimate(a, factor), Decimate(b, factor), win/factor
	}
	if searchWin <= 0 || searchWin > len(searchA) || searchWin > len(searchB) {
		return nil
	}

	step := searchWin / candidateStepDivisor
	if step < 1 {
		step = 1
	}
	target := PrepareTarget(searchB, searchWin)

	type scored struct {
		position int
		score    float64
	}
	var all []scored
	for start := 0; start+searchWin <= len(searchA); start += step {
		if m := BestMatchPrepared(searchA[start:start+searchWin], target); m != nil {
			all = append(all, scored{position: start * factor, score: m.Score})
		}
	}

	// Highest-scoring first, but one entry per distinct region of a.
	//
	// Without the spacing rule the shortlist fills with overlapping slices of whichever
	// segment scores best, and any other recurring segment never gets looked at. That is not
	// hypothetical: a rotating ad present in two episodes produced three near-identical
	// candidates covering itself, so the intro shared by all four was never evaluated and the
	// detector confirmed the ad. Candidates have to describe different audio, not the same
	// audio three times.
	keep := t.FineCandidates
	if keep < 1 {
		keep = 1
	}
	out := make([]int, 0, keep)
	used := make([]bool, len(all))
	for len(out) < keep {
		bestIdx := -1
		for i, s := range all {
			if used[i] {
				continue
			}
			if bestIdx < 0 || s.score > all[bestIdx].score {
				bestIdx = i
			}
		}
		if bestIdx < 0 {
			break
		}
		used[bestIdx] = true

		position := all[bestIdx].position
		if !separated(out, position, win) {
			continue
		}
		out = append(out, position)
	}
	return out
}

// separated reports whether position describes a different stretch of audio than everything
// already shortlisted.
func separated(chosen []int, position, spacing int) bool {
	for _, c := range chosen {
		d := c - position
		if d < 0 {
			d = -d
		}
		if d < spacing {
			return false
		}
	}
	return true
}

// locateInAll finds reference in every clip at full resolution, keeping those that clear
// minScore. This is where detection actually happens: every number the coarse pass produced is
// discarded and the decision is made on native-resolution audio.
func locateInAll(reference []float64, clips []Clip, minScore float64) []Occurrence {
	var found []Occurrence
	for _, c := range clips {
		m := BestMatch(reference, c.Samples)
		if m == nil || m.Score < minScore {
			continue
		}
		found = append(found, Occurrence{EpisodeID: c.EpisodeID, Position: m.Position, Score: m.Score})
	}
	return found
}

func coarseFactor(win, requested int) int {
	if requested < 2 {
		return 1
	}
	if win/requested < minCoarseWindow {
		return 1
	}
	return requested
}

func clampStart(position, length, win int) int {
	if position < 0 {
		return 0
	}
	if position > length-win {
		return length - win
	}
	return position
}