summaryrefslogtreecommitdiff
path: root/server/internal/detect/lags.go
blob: 18df583b646a573ae85b3a15dd6263948f1940b3 (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
230
231
232
233
234
235
236
237
238
package detect

import "math"

// minLagSeparation keeps the shortlist to one entry per shared segment.
//
// A real match spreads its energy over neighbouring lags, so without this a single
// advertisement fills every slot on the shortlist and the second one is never looked at.
const minLagSeparation = 64

type scoredLag struct {
	lag   int
	score float64
}

// BestLags shortlists the alignments at which a and b most strongly share audio.
//
// One cross-correlation over every lag, not a correlation per window position. That is the
// whole reason whole-episode scanning is possible: the sliding search it replaces cost a
// full-length correlation for each of 117 window positions -- 199 seconds for a 240-second
// clip, measured -- while this costs one FFT pair for the entire pair of recordings. A
// 70-minute episode decimated to 250Hz is three transforms of 2^22, about five seconds.
//
// A shortlist only. The scores here are unnormalised and favour loud or long overlaps, so they
// rank candidates and decide nothing: [MatchedRuns] establishes whether a lag means anything
// and how far the agreement actually extends.
//
// Lag is defined so that a[i] lines up with b[i-lag].
func BestLags(a, b []float64, keep int) []int {
	if len(a) == 0 || len(b) == 0 || keep < 1 {
		return nil
	}

	// Mean-centred, so two recordings that merely share a DC offset do not outscore two that
	// share actual audio.
	ca, cb := centred(a), centred(b)

	// Correlation is convolution with one input reversed.
	n := NextPowerOfTwo(len(ca) + len(cb))
	aRe := make([]float64, n)
	copy(aRe, ca)
	aIm := make([]float64, n)
	bRe := make([]float64, n)
	for i, v := range cb {
		bRe[len(cb)-1-i] = v
	}
	bIm := make([]float64, n)

	Transform(aRe, aIm, false)
	Transform(bRe, bIm, false)
	for i := 0; i < n; i++ {
		re := aRe[i]*bRe[i] - aIm[i]*bIm[i]
		im := aRe[i]*bIm[i] + aIm[i]*bRe[i]
		aRe[i], aIm[i] = re, im
	}
	Transform(aRe, aIm, true)

	// Index k of the convolution corresponds to lag k - (len(b) - 1).
	offset := len(cb) - 1
	best := make([]scoredLag, 0, keep)
	for k := 0; k < len(ca)+len(cb)-1; k++ {
		best = offer(best, scoredLag{lag: k - offset, score: math.Abs(aRe[k])}, keep)
	}

	// Strongest first, by insertion sort: the shortlist is a handful of entries.
	for i := 1; i < len(best); i++ {
		for j := i; j > 0 && best[j].score > best[j-1].score; j-- {
			best[j], best[j-1] = best[j-1], best[j]
		}
	}
	out := make([]int, 0, len(best))
	for _, s := range best {
		out = append(out, s.lag)
	}
	return out
}

// offer adds a candidate to the shortlist, keeping one entry per neighbourhood of lags and at
// most keep entries overall.
func offer(best []scoredLag, candidate scoredLag, keep int) []scoredLag {
	for i := range best {
		if abs(best[i].lag-candidate.lag) <= minLagSeparation {
			if candidate.score > best[i].score {
				best[i] = candidate
			}
			return best
		}
	}
	if len(best) < keep {
		return append(best, candidate)
	}
	weakest := 0
	for i := range best {
		if best[i].score < best[weakest].score {
			weakest = i
		}
	}
	if candidate.score > best[weakest].score {
		best[weakest] = candidate
	}
	return best
}

// Run is a stretch over which two recordings carry the same audio, in a's own sample positions.
type Run struct {
	StartA int
	EndA   int // exclusive
	// StartB is where the same audio begins in b.
	StartB int
	// Score is the weakest window correlation inside the run, so it describes the whole run
	// rather than its best moment.
	Score float64
}

// Length is the run's duration in samples.
func (r Run) Length() int { return r.EndA - r.StartA }

// RunOptions governs what counts as agreement.
type RunOptions struct {
	// WindowSamples is the granularity the comparison is made at.
	WindowSamples int
	// MinScore is the correlation a window must reach to count as the same audio.
	MinScore float64
	// MinRunSamples discards agreement too short to be a real inserted segment. Two
	// recordings will agree briefly by chance, and on silence they will agree completely.
	MinRunSamples int
}

// DefaultRunOptions is sized for audio decimated to 250Hz: one-second windows, and nothing
// shorter than twelve seconds taken seriously.
//
// Advertising comes in units of roughly fifteen seconds, grouped into pods with real programme
// between them. Below twelve the "agreement" is the detector matching itself by chance, not an
// advertisement: a real 38-minute episode produced 37 breaks against the old 1000-sample (4s)
// floor, 14 of them landing at *exactly* 4.0 seconds -- noise pinned at the floor, not findings.
// Episodes the feed proves carry advertising (served measurably longer than declared) have a
// median of 3 breaks each, never more than 7. Twelve rather than fifteen so a real 15-second ad
// whose correlation lapses briefly at one end still survives; both floors were measured against
// the declared-versus-served length gap and twelve scored better.
func DefaultRunOptions() RunOptions {
	return RunOptions{WindowSamples: 250, MinScore: 0.75, MinRunSamples: 3000}
}

// MatchedRuns reports the stretches over which a and b carry the same audio at the given lag.
//
// Direct, window by window, with no transform: once the alignment is known the comparison is a
// Pearson correlation over short windows, which is cheap and gives the extent of the agreement
// rather than only its strength. The extent is the point -- an advertisement has to be skipped
// in full, so where it stops matters as much as that it is there.
//
// Silent windows are not counted as agreement. Two recordings agree perfectly on silence, and
// treating that as shared audio would join unrelated segments across the gaps between them.
func MatchedRuns(a, b []float64, lag int, opts RunOptions) []Run {
	if opts.WindowSamples <= 0 || len(a) == 0 || len(b) == 0 {
		return nil
	}

	var runs []Run
	runStart := -1
	runScore := 1.0

	for start := 0; start+opts.WindowSamples <= len(a); start += opts.WindowSamples {
		bStart := start - lag
		score, ok := windowAgreement(a, b, start, bStart, opts.WindowSamples)

		if ok && score >= opts.MinScore {
			if runStart < 0 {
				runStart, runScore = start, score
			} else if score < runScore {
				runScore = score
			}
			continue
		}
		runs = closeRun(runs, runStart, start, lag, runScore, opts)
		runStart, runScore = -1, 1.0
	}
	end := (len(a) / opts.WindowSamples) * opts.WindowSamples
	return closeRun(runs, runStart, end, lag, runScore, opts)
}

func closeRun(runs []Run, runStart, end, lag int, score float64, opts RunOptions) []Run {
	if runStart < 0 || end-runStart < opts.MinRunSamples {
		return runs
	}
	return append(runs, Run{StartA: runStart, EndA: end, StartB: runStart - lag, Score: score})
}

// windowAgreement is the Pearson correlation of one aligned window, and whether it carries
// enough signal to mean anything.
func windowAgreement(a, b []float64, aStart, bStart, win int) (float64, bool) {
	if aStart < 0 || bStart < 0 || aStart+win > len(a) || bStart+win > len(b) {
		return 0, false
	}

	var sumA, sumB float64
	for i := 0; i < win; i++ {
		sumA += a[aStart+i]
		sumB += b[bStart+i]
	}
	meanA, meanB := sumA/float64(win), sumB/float64(win)

	var cov, varA, varB float64
	for i := 0; i < win; i++ {
		da, db := a[aStart+i]-meanA, b[bStart+i]-meanB
		cov += da * db
		varA += da * da
		varB += db * db
	}
	// Near-silence in either window: perfectly correlated and meaningless.
	if varA < silenceFloor || varB < silenceFloor {
		return 0, false
	}
	return cov / math.Sqrt(varA*varB), true
}

// silenceFloor is the variance below which a window carries no signal worth comparing. Scaled
// for 16-bit-derived samples, where ordinary speech sits in the hundreds.
const silenceFloor = 1e-6

func centred(x []float64) []float64 {
	mean := 0.0
	for _, v := range x {
		mean += v
	}
	mean /= float64(len(x))
	out := make([]float64, len(x))
	for i, v := range x {
		out[i] = v - mean
	}
	return out
}

func abs(n int) int {
	if n < 0 {
		return -n
	}
	return n
}