package signature import ( "context" "github.com/terst/listen/server/internal/advertising" ) // Audio supplies decoded audio at [SampleRate]. type Audio interface { // Episode decodes a whole episode. Episode(ctx context.Context, id string) ([]float64, error) // Range decodes one stretch of an episode. Range(ctx context.Context, id string, startMs, durationMs int64) ([]float64, error) } // ShadowBreak is one stretch detection decided about. type ShadowBreak struct { StartMs int64 EndMs int64 // Partners is how many other episodes detection found carrying this audio. It is what // makes an advertisement a confirmed repeat rather than a single sighting. Partners int // Kind is what skips.Classify called it: "ad", or one of the show's own "intro", "cue", // "outro". Kind string } // ShadowLabel is something the listener said about a stretch of this episode. type ShadowLabel struct { StartMs int64 EndMs int64 // Advertising is true when he said this is an ad -- the notification button, or marking a // break the detector found as right. False when he said it is the programme. Advertising bool // EndMeasured is false when the end is wherever the jump landed rather than where he heard // the advertising stop. See [GuessedEndSeedMs]. EndMeasured bool } // ShadowEpisode is one episode of a batch, what detection made of it, and what the listener // said about it. type ShadowEpisode struct { ID string FeedURL string Breaks []ShadowBreak Labels []ShadowLabel } // ShadowBatch is one detection pass over one show. type ShadowBatch struct { // Occasion identifies the pass. Everything in it counts as a single sighting, because the // episodes were mirrored together and all carry whatever campaign was running that minute. Occasion string AtUnix int64 Episodes []ShadowEpisode } // Proposal is a stretch the library would cut. type Proposal struct { EpisodeID string StartMs int64 EndMs int64 Entry *Entry Votes int // Novel is true when detection did not already publish a break covering this. Those are // the interesting ones: the library earning its place rather than agreeing with what is // already known. Novel bool } // GuessedEndSeedMs is how much of a reported advertisement is kept when its end was never // measured. // // An uncorrected end is wherever the 25-second jump happened to land, not where the advertising // stopped, so the tail of that range may be programme -- and a signature carrying programme is // the one thing this must never build. One advertising slot from the start is far more audio // than a landmark fingerprint needs to be distinctive, and it is the part most certainly // advertising. const GuessedEndSeedMs = advertising.UnitMs // Report is what one shadow run saw. type Report struct { Harvested int Reinforced int // Disowned counts signatures dropped because the listener said that audio is the programme. Disowned int WouldCut []Proposal // Skipped counts episodes whose audio could not be read. Reported rather than failing the // run: one unreadable file must not cost the whole batch's learning. Skipped int } // Shadow learns from a detection pass and reports what the library would do, without doing it. // // Two jobs, deliberately in one place. It harvests signatures from what detection found, and it // matches what it already holds against the same audio. Running those together is what makes the // log honest: the proposals in a report were made by a library that did not yet know about this // batch. type Shadow struct { library *Library audio Audio // MinSightings is how many separate occasions a signature needs before it is matched // against. Never one: a batch is mirrored in a minute and shares one campaign. MinSightings int } // NewShadow returns a shadow runner over the given library. func NewShadow(library *Library, audio Audio) *Shadow { return &Shadow{library: library, audio: audio, MinSightings: 2} } // Run matches first, then harvests. // // The order is the point. Harvesting first would let this batch's own signatures match the batch // they came from, and every run would report itself as a triumph. func (s *Shadow) Run(ctx context.Context, batch ShadowBatch) (Report, error) { var report Report // Counted per episode, not per failure: an episode that could not be read fails both the // matching pass and the harvesting pass, and reporting it twice would overstate the damage. unreadable := map[string]bool{} for _, ep := range batch.Episodes { if ctx.Err() != nil { report.Skipped = len(unreadable) return report, ctx.Err() } samples, err := s.audio.Episode(ctx, ep.ID) if err != nil || len(samples) == 0 { unreadable[ep.ID] = true continue } for _, m := range s.library.Match(Take(samples), s.MinSightings) { report.WouldCut = append(report.WouldCut, Proposal{ EpisodeID: ep.ID, StartMs: m.StartMs, EndMs: m.EndMs, Entry: m.Entry, Votes: m.Votes, Novel: !covered(ep.Breaks, m.StartMs, m.EndMs), }) } } for _, ep := range batch.Episodes { if ctx.Err() != nil { report.Skipped = len(unreadable) return report, ctx.Err() } // His "no" first, and it does two things: it stops the harvest below from storing // audio he says is the programme, and it takes back anything already held that matches // it. A signature built from programme would cut the programme in every episode // carrying it, until somebody noticed. for _, label := range ep.Labels { if label.Advertising { continue } if !s.disown(ctx, ep, label, &report) { unreadable[ep.ID] = true } } for _, br := range ep.Breaks { if disowned(ep.Labels, br.StartMs, br.EndMs) { continue } // A break this cannot read is counted and stepped over. Every other per-item loop // in this server isolates a single failure the same way, and the first real run // lost a whole show's learning to one episode whose path was wrong. if !s.harvest(ctx, batch, ep, seed{ startMs: br.StartMs, endMs: br.EndMs, kind: kindFor(br.Kind), partners: br.Partners, confirmed: vouchedFor(ep.Labels, br.StartMs, br.EndMs), }, &report) { unreadable[ep.ID] = true break } } // Then what he pointed at himself. Advertising detection missed is the gap the library // exists to close, so a report is harvested whether or not a break was found there. for _, label := range ep.Labels { if !label.Advertising || coveredByBreak(ep.Breaks, label) { continue } if !s.harvest(ctx, batch, ep, seedFromLabel(label), &report) { unreadable[ep.ID] = true break } } } report.Skipped = len(unreadable) return report, nil } // seed is one stretch about to become a signature. type seed struct { startMs int64 endMs int64 kind string partners int confirmed bool } func seedFromLabel(label ShadowLabel) seed { end := label.EndMs if !label.EndMeasured && end-label.StartMs > GuessedEndSeedMs { end = label.StartMs + GuessedEndSeedMs } // Always a copy: he presses the button when he hears an advertisement, and a show's own // furniture is not what that button is for. return seed{startMs: label.StartMs, endMs: end, kind: KindCopy, confirmed: true} } func overlaps(aStart, aEnd, bStart, bEnd int64) bool { return aStart < bEnd && aEnd > bStart } // disowned says whether the listener called this stretch the programme. func disowned(labels []ShadowLabel, startMs, endMs int64) bool { for _, l := range labels { if !l.Advertising && overlaps(startMs, endMs, l.StartMs, l.EndMs) { return true } } return false } // vouchedFor says whether the listener marked this break right. func vouchedFor(labels []ShadowLabel, startMs, endMs int64) bool { for _, l := range labels { if l.Advertising && overlaps(startMs, endMs, l.StartMs, l.EndMs) { return true } } return false } // coveredByBreak says whether detection already found this label's audio, in which case // harvesting the break covered it and the label only had to vouch for it. func coveredByBreak(breaks []ShadowBreak, label ShadowLabel) bool { for _, br := range breaks { if overlaps(label.StartMs, label.EndMs, br.StartMs, br.EndMs) { return true } } return false } // disown forgets every signature matching audio the listener says is the programme. func (s *Shadow) disown(ctx context.Context, ep ShadowEpisode, label ShadowLabel, report *Report) bool { samples, err := s.audio.Range(ctx, ep.ID, label.StartMs, label.EndMs-label.StartMs) if err != nil { return false } report.Disowned += s.library.Disown(Take(samples)) return true } // harvest stores one stretch of audio as a signature, and reports whether the episode could be // read at all. A false means give up on this episode, not on the batch. func (s *Shadow) harvest(ctx context.Context, batch ShadowBatch, ep ShadowEpisode, sd seed, report *Report) bool { duration := sd.endMs - sd.startMs if duration <= 0 { return true } samples, err := s.audio.Range(ctx, ep.ID, sd.startMs, duration) if err != nil { return false } fp := Take(samples) if len(fp.Hashes) == 0 { return true } _, isNew := s.library.Observe(Observation{ Fingerprint: fp, Kind: sd.kind, FeedURL: ep.FeedURL, DurationMs: duration, Partners: sd.partners, Confirmed: sd.confirmed, Occasion: batch.Occasion, }, batch.AtUnix) if isNew { report.Harvested++ } else { report.Reinforced++ } return true } // kindFor maps what detection called a break onto what the library stores. // // Advertising belongs to nobody and expires; everything else is the show's own audio and does // not. Anything unrecognised is treated as advertising, matching the app's rule that a kind a // newer server invented must not quietly become permanent furniture. func kindFor(breakKind string) string { switch breakKind { case "intro", "cue", "outro": return KindCue default: return KindCopy } } // covered says whether detection already published a break overlapping this stretch. func covered(breaks []ShadowBreak, startMs, endMs int64) bool { for _, br := range breaks { if startMs < br.EndMs && endMs > br.StartMs { return true } } return false }