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] }