summaryrefslogtreecommitdiff
path: root/server/internal/skips/selfcheck.go
blob: 6714b70c92738cf035ba96ee791736699fb0a2e4 (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
package skips

import (
	"github.com/terst/listen/server/internal/detect"
)

// CheckReport is the outcome of verifying a confirmation against itself.
type CheckReport struct {
	OK    bool
	Pairs int
	// WorstA, WorstB and WorstScore identify the least convincing pair, which is what a
	// failure needs to say to be actionable.
	WorstA     string
	WorstB     string
	WorstScore float64
}

// slopSamples is how far a reported position may be out before the check calls it a mismatch.
//
// Half a second. A genuine position can differ from a reported one by rounding -- reading an
// offset that was displayed to the nearest second is the first thing that produced a false
// mismatch here -- while a segment that is actually different audio is wrong by far more than
// this. Without the tolerance the check cries wolf; with much more of it, a real error hides.
const slopSamples = 4000

// SelfCheck verifies a confirmation by correlating the located segments against each other
// rather than against the reference they were found with.
//
// This asks a different question from the detection threshold, which is why it catches things
// the threshold cannot. "Each of these scored above 0.6 against one reference" does not imply
// "these are all the same audio": correlation is not transitive, and two segments can each
// clear a threshold against a reference while being unrelated to one another. Publishing skip
// offsets derived from such a set would cut different audio out of different episodes, which
// is precisely the failure a skip feature must never have.
//
// Cheap enough to run every time: a handful of correlations over eight-second clips.
func SelfCheck(found *detect.Recurring, clips []detect.Clip, minScore float64) CheckReport {
	if found == nil || len(found.Occurrences) < 2 {
		return CheckReport{OK: true}
	}

	byID := make(map[string][]float64, len(clips))
	for _, c := range clips {
		byID[c.EpisodeID] = c.Samples
	}

	segments := make(map[string][]float64, len(found.Occurrences))
	for _, o := range found.Occurrences {
		samples, ok := byID[o.EpisodeID]
		if !ok {
			continue
		}
		// Widened by the tolerance at both ends, so a position that is slightly out still has
		// the whole segment inside the window being compared.
		from := o.Position - slopSamples
		if from < 0 {
			from = 0
		}
		to := o.Position + len(found.Reference) + slopSamples
		if to > len(samples) {
			to = len(samples)
		}
		if to-from > 0 {
			segments[o.EpisodeID] = samples[from:to]
		}
	}

	report := CheckReport{OK: true, WorstScore: 1}
	ids := make([]string, 0, len(segments))
	for _, o := range found.Occurrences {
		if _, ok := segments[o.EpisodeID]; ok {
			ids = append(ids, o.EpisodeID)
		}
	}

	for i := 0; i < len(ids); i++ {
		for j := i + 1; j < len(ids); j++ {
			// The query is the reference-length core of one segment; the target is the other
			// segment with its tolerance either side, so the search absorbs the slop.
			query := core(segments[ids[i]], len(found.Reference))
			target := segments[ids[j]]
			score := 0.0
			if m := detect.BestMatch(query, target); m != nil {
				score = m.Score
			}
			report.Pairs++
			if score < report.WorstScore {
				report.WorstScore, report.WorstA, report.WorstB = score, ids[i], ids[j]
			}
			if score < minScore {
				report.OK = false
			}
		}
	}
	return report
}

// core takes the middle referenceLen samples of a widened segment, so the query is the segment
// itself rather than the segment plus the tolerance around it.
func core(segment []float64, referenceLen int) []float64 {
	if len(segment) <= referenceLen {
		return segment
	}
	start := (len(segment) - referenceLen) / 2
	return segment[start : start+referenceLen]
}