diff options
Diffstat (limited to 'server/internal')
| -rw-r--r-- | server/internal/signature/library.go | 42 | ||||
| -rw-r--r-- | server/internal/signature/library_test.go | 69 | ||||
| -rw-r--r-- | server/internal/signature/shadow.go | 177 | ||||
| -rw-r--r-- | server/internal/signature/shadow_test.go | 183 |
4 files changed, 470 insertions, 1 deletions
diff --git a/server/internal/signature/library.go b/server/internal/signature/library.go index 065c049..92bf77b 100644 --- a/server/internal/signature/library.go +++ b/server/internal/signature/library.go @@ -39,6 +39,9 @@ type Observation struct { // FeedURL is the show a cue belongs to. Ignored for a copy. FeedURL string DurationMs int64 + // Partners is how many episodes in this one batch carried the audio, as detection + // measured it. Within-batch corroboration, which is not the same thing as independence. + Partners int // Occasion is what makes two sightings independent. A whole batch of episodes mirrored in // one minute is one occasion, however many of them carried the audio -- they all ran // whatever campaign was running that minute, which is exactly why eight agreeing episodes @@ -52,6 +55,8 @@ type Entry struct { Kind string `json:"kind"` FeedURL string `json:"feedUrl,omitempty"` DurationMs int64 `json:"durationMs"` + // Partners is the most episodes ever seen sharing this in a single batch. + Partners int `json:"partners,omitempty"` Fingerprint Fingerprint `json:"fingerprint"` @@ -64,6 +69,26 @@ type Entry struct { // Confidence is how many separate occasions this signature has been seen on. func (e *Entry) Confidence() int { return len(e.Occasions) } +// Trusted says whether this is worth matching against, and the rule differs by kind. +// +// A cue needs [minOccasions] separate occasions, no exceptions. That is the independence test +// the withheld intro and outro offsets have been waiting for: eight episodes mirrored in one +// minute all carry whatever campaign is running, so agreement across them says nothing about +// whether audio is the show's own. Only agreement across *weeks* does. +// +// An advertisement is different, and the difference is not a loophole. Detection publishes a +// break only because the audio recurred across several episodes, verified by correlating them +// against each other -- so a copy that arrived with partners is already a confirmed repeat, not +// a single unsupported sighting. Holding it back for a second week would mean the library could +// only ever propose advertising that detection had already found twice, which is precisely when +// it has nothing to add. +func (e *Entry) Trusted(minOccasions int) bool { + if e.Confidence() >= minOccasions { + return true + } + return e.Kind == KindCopy && e.Partners >= 2 +} + func (e *Entry) seenOn(occasion string) bool { for _, o := range e.Occasions { if o == occasion { @@ -98,12 +123,16 @@ func (l *Library) Observe(o Observation, atUnix int64) (*Entry, bool) { if atUnix > existing.LastSeenUnix { existing.LastSeenUnix = atUnix } + if o.Partners > existing.Partners { + existing.Partners = o.Partners + } return existing, false } e := &Entry{ ID: fingerprintID(o.Fingerprint), Kind: o.Kind, DurationMs: o.DurationMs, + Partners: o.Partners, Fingerprint: o.Fingerprint, Occasions: []string{o.Occasion}, FirstSeenUnix: atUnix, @@ -152,6 +181,17 @@ func (l *Library) Confident(minSightings int) []*Entry { return out } +// Trusted returns the signatures worth matching against; see [Entry.Trusted]. +func (l *Library) Trusted(minOccasions int) []*Entry { + var out []*Entry + for _, e := range l.entries { + if e.Trusted(minOccasions) { + out = append(out, e) + } + } + return out +} + // Age forgets advertisements nobody has heard in a long time, and returns how many went. // // Cues are never aged out: a show's break stinger is not a campaign, and a show that goes quiet @@ -207,7 +247,7 @@ type Match struct { // is cut from what somebody is listening to. func (l *Library) Match(fp Fingerprint, minSightings int) []Match { trusted := map[string]*Entry{} - for _, e := range l.Confident(minSightings) { + for _, e := range l.Trusted(minSightings) { trusted[e.ID] = e } if len(trusted) == 0 { diff --git a/server/internal/signature/library_test.go b/server/internal/signature/library_test.go index 9231485..8290b19 100644 --- a/server/internal/signature/library_test.go +++ b/server/internal/signature/library_test.go @@ -200,3 +200,72 @@ func TestAnEpisodeCarryingNothingStoredMatchesNothing(t *testing.T) { t.Fatalf("got %+v", m) } } + +// --- what is worth matching against --------------------------------------------------------- + +func observe(l *signature.Library, o signature.Observation, atUnix int64) *signature.Entry { + e, _ := l.Observe(o, atUnix) + return e +} + +func TestAnAdvertisementDetectionConfirmedIsWorthMatchingAtOnce(t *testing.T) { + l := signature.NewLibrary() + + e := observe(l, signature.Observation{ + Fingerprint: fpOf(42, 20), Kind: signature.KindCopy, DurationMs: 20_000, + Occasion: "batch-1", Partners: 4, + }, day) + + if !e.Trusted(2) { + t.Fatal("a break is only published because the audio recurred across episodes and was " + + "correlated to prove it; holding that back a week means only ever proposing " + + "advertising that was already found twice") + } +} + +func TestAnAdvertisementNothingCorroboratedWaitsForAnotherWeek(t *testing.T) { + l := signature.NewLibrary() + + e := observe(l, signature.Observation{ + Fingerprint: fpOf(42, 20), Kind: signature.KindCopy, DurationMs: 20_000, + Occasion: "batch-1", + }, day) + + if e.Trusted(2) { + t.Fatal("one sighting with nothing backing it is not evidence") + } +} + +// The independence test the withheld intro and outro offsets are waiting for. Eight episodes +// mirrored in one minute all carry whatever campaign is running, so agreement across them says +// nothing about whether audio is the show's own -- only agreement across weeks does. +func TestFurnitureGetsNoShortcutHoweverManyEpisodesAgreed(t *testing.T) { + l := signature.NewLibrary() + + e := observe(l, signature.Observation{ + Fingerprint: fpOf(7, 8), Kind: signature.KindCue, FeedURL: "https://x/f.xml", + DurationMs: 8_000, Occasion: "batch-1", Partners: 8, + }, day) + + if e.Trusted(2) { + t.Fatal("eight episodes mirrored together are one observation, and a sting shared by " + + "all of them was an advertisement once already") + } +} + +func TestTheMostEpisodesEverSharingItIsWhatCounts(t *testing.T) { + l := signature.NewLibrary() + observe(l, signature.Observation{ + Fingerprint: fpOf(42, 20), Kind: signature.KindCopy, DurationMs: 20_000, + Occasion: "batch-1", Partners: 6, + }, day) + + e := observe(l, signature.Observation{ + Fingerprint: fpOf(42, 20), Kind: signature.KindCopy, DurationMs: 20_000, + Occasion: "batch-2", Partners: 1, + }, 8*day) + + if e.Partners != 6 { + t.Fatalf("partners %d -- a quieter week does not unmake what was measured before", e.Partners) + } +} diff --git a/server/internal/signature/shadow.go b/server/internal/signature/shadow.go new file mode 100644 index 0000000..9c2fe6b --- /dev/null +++ b/server/internal/signature/shadow.go @@ -0,0 +1,177 @@ +package signature + +import ( + "context" + "fmt" +) + +// 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 +} + +// ShadowEpisode is one episode of a batch and what detection made of it. +type ShadowEpisode struct { + ID string + FeedURL string + Breaks []ShadowBreak +} + +// 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 +} + +// Report is what one shadow run saw. +type Report struct { + Harvested int + Reinforced 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 + for _, ep := range batch.Episodes { + if ctx.Err() != nil { + return report, ctx.Err() + } + samples, err := s.audio.Episode(ctx, ep.ID) + if err != nil || len(samples) == 0 { + report.Skipped++ + 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 { + return report, ctx.Err() + } + for _, br := range ep.Breaks { + if err := s.harvest(ctx, batch, ep, br, &report); err != nil { + return report, err + } + } + } + return report, nil +} + +func (s *Shadow) harvest(ctx context.Context, batch ShadowBatch, ep ShadowEpisode, br ShadowBreak, report *Report) error { + duration := br.EndMs - br.StartMs + if duration <= 0 { + return nil + } + samples, err := s.audio.Range(ctx, ep.ID, br.StartMs, duration) + if err != nil { + return fmt.Errorf("decode %s %d-%dms: %w", ep.ID, br.StartMs, br.EndMs, err) + } + fp := Take(samples) + if len(fp.Hashes) == 0 { + return nil + } + _, isNew := s.library.Observe(Observation{ + Fingerprint: fp, + Kind: kindFor(br.Kind), + FeedURL: ep.FeedURL, + DurationMs: duration, + Partners: br.Partners, + Occasion: batch.Occasion, + }, batch.AtUnix) + if isNew { + report.Harvested++ + } else { + report.Reinforced++ + } + return nil +} + +// 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 +} diff --git a/server/internal/signature/shadow_test.go b/server/internal/signature/shadow_test.go new file mode 100644 index 0000000..3e6336f --- /dev/null +++ b/server/internal/signature/shadow_test.go @@ -0,0 +1,183 @@ +package signature_test + +import ( + "context" + "testing" + + "github.com/terst/listen/server/internal/signature" +) + +// fakeAudio serves audio the test planted, so a shadow run can be exercised without ffmpeg. +type fakeAudio map[string][]float64 + +func (f fakeAudio) Episode(_ context.Context, id string) ([]float64, error) { + return f[id], nil +} + +func (f fakeAudio) Range(_ context.Context, id string, startMs, durMs int64) ([]float64, error) { + all := f[id] + from := int(startMs) * signature.SampleRate / 1000 + to := from + int(durMs)*signature.SampleRate/1000 + if from > len(all) { + return nil, nil + } + if to > len(all) { + to = len(all) + } + return all[from:to], nil +} + +func batch(episodes ...signature.ShadowEpisode) signature.ShadowBatch { + return signature.ShadowBatch{Occasion: "batch-1", AtUnix: day, Episodes: episodes} +} + +func TestHarvestsAnAdvertisementAsACopyAndFurnitureAsACue(t *testing.T) { + ad := tone(42, seconds(20)) + cue := tone(7, seconds(8)) + audio := fakeAudio{ + "e1": planted(1, ad, 300, 60), + "e2": planted(2, cue, 300, 30), + } + l := signature.NewLibrary() + shadow := signature.NewShadow(l, audio) + + report, err := shadow.Run(context.Background(), batch( + signature.ShadowEpisode{ID: "e1", Breaks: []signature.ShadowBreak{{StartMs: 60_000, EndMs: 80_000, Kind: "ad"}}}, + signature.ShadowEpisode{ID: "e2", FeedURL: "https://x/f.xml", + Breaks: []signature.ShadowBreak{{StartMs: 30_000, EndMs: 38_000, Kind: "cue"}}}, + )) + if err != nil { + t.Fatalf("run: %v", err) + } + + if report.Harvested != 2 { + t.Fatalf("harvested %d, want 2", report.Harvested) + } + kinds := map[string]string{} + for _, e := range l.Confident(1) { + kinds[e.Kind] = e.FeedURL + } + if _, ok := kinds[signature.KindCopy]; !ok { + t.Fatal("an advertising break should be stored as a copy") + } + if kinds[signature.KindCue] != "https://x/f.xml" { + t.Fatalf("a cue should carry its show, got %q", kinds[signature.KindCue]) + } +} + +func TestOneBatchIsOneSightingHoweverManyEpisodesCarriedIt(t *testing.T) { + ad := tone(42, seconds(20)) + audio := fakeAudio{"e1": planted(1, ad, 300, 60), "e2": planted(2, ad, 300, 90)} + l := signature.NewLibrary() + shadow := signature.NewShadow(l, audio) + + _, err := shadow.Run(context.Background(), batch( + signature.ShadowEpisode{ID: "e1", Breaks: []signature.ShadowBreak{{StartMs: 60_000, EndMs: 80_000, Kind: "ad"}}}, + signature.ShadowEpisode{ID: "e2", Breaks: []signature.ShadowBreak{{StartMs: 90_000, EndMs: 110_000, Kind: "ad"}}}, + )) + if err != nil { + t.Fatalf("run: %v", err) + } + + for _, e := range l.Confident(1) { + if e.Confidence() != 1 { + t.Fatalf("confidence %d from a single batch -- eight episodes mirrored together are one observation", e.Confidence()) + } + } +} + +func TestASecondPassFindsWhatTheFirstOnlyLearned(t *testing.T) { + ad := tone(42, seconds(20)) + l := signature.NewLibrary() + + // Week one: the detector finds the advertisement in e1, and the library learns it. + first := fakeAudio{"e1": planted(1, ad, 300, 60)} + _, err := signature.NewShadow(l, first).Run(context.Background(), signature.ShadowBatch{ + Occasion: "week-1", AtUnix: day, + Episodes: []signature.ShadowEpisode{{ID: "e1", + Breaks: []signature.ShadowBreak{{StartMs: 60_000, EndMs: 80_000, Kind: "ad", Partners: 4}}}}, + }) + if err != nil { + t.Fatalf("week one: %v", err) + } + + // Week two: the same advertisement is in a different episode, and detection missed it. + second := fakeAudio{"e9": planted(5, ad, 300, 200)} + report, err := signature.NewShadow(l, second).Run(context.Background(), signature.ShadowBatch{ + Occasion: "week-2", AtUnix: 8 * day, + Episodes: []signature.ShadowEpisode{{ID: "e9"}}, + }) + if err != nil { + t.Fatalf("week two: %v", err) + } + + var novel []signature.Proposal + for _, p := range report.WouldCut { + if p.Novel { + novel = append(novel, p) + } + } + if len(novel) != 1 { + t.Fatalf("got %d novel proposals from %+v, want the advertisement detection missed", len(novel), report.WouldCut) + } + if novel[0].StartMs < 199_000 || novel[0].StartMs > 201_000 { + t.Fatalf("placed at %dms, planted at 200000", novel[0].StartMs) + } +} + +func TestAProposalDetectionAlreadyFoundIsNotNews(t *testing.T) { + ad := tone(42, seconds(20)) + l := signature.NewLibrary() + first := fakeAudio{"e1": planted(1, ad, 300, 60)} + signature.NewShadow(l, first).Run(context.Background(), signature.ShadowBatch{ + Occasion: "week-1", AtUnix: day, + Episodes: []signature.ShadowEpisode{{ID: "e1", + Breaks: []signature.ShadowBreak{{StartMs: 60_000, EndMs: 80_000, Kind: "ad"}}}}, + }) + + // Week two: same advertisement, same place, and this time detection found it too. + second := fakeAudio{"e2": planted(3, ad, 300, 120)} + report, _ := signature.NewShadow(l, second).Run(context.Background(), signature.ShadowBatch{ + Occasion: "week-2", AtUnix: 8 * day, + Episodes: []signature.ShadowEpisode{{ID: "e2", + Breaks: []signature.ShadowBreak{{StartMs: 120_000, EndMs: 140_000, Kind: "ad"}}}}, + }) + + for _, p := range report.WouldCut { + if p.Novel { + t.Fatalf("called a break the detector already published novel: %+v", p) + } + } +} + +func TestAnEpisodeCarryingNothingKnownProposesNothing(t *testing.T) { + ad := tone(42, seconds(20)) + l := signature.NewLibrary() + signature.NewShadow(l, fakeAudio{"e1": planted(1, ad, 300, 60)}).Run(context.Background(), + signature.ShadowBatch{Occasion: "week-1", AtUnix: day, + Episodes: []signature.ShadowEpisode{{ID: "e1", + Breaks: []signature.ShadowBreak{{StartMs: 60_000, EndMs: 80_000, Kind: "ad"}}}}}) + + report, _ := signature.NewShadow(l, fakeAudio{"e2": tone(99, seconds(300))}).Run(context.Background(), + signature.ShadowBatch{Occasion: "week-2", AtUnix: 8 * day, + Episodes: []signature.ShadowEpisode{{ID: "e2"}}}) + + if len(report.WouldCut) != 0 { + t.Fatalf("proposed %+v against an episode with none of it", report.WouldCut) + } +} + +// The whole point of a shadow run: it watches, it does not act. +func TestAShadowRunPublishesNothing(t *testing.T) { + ad := tone(42, seconds(20)) + l := signature.NewLibrary() + episode := signature.ShadowEpisode{ID: "e1", + Breaks: []signature.ShadowBreak{{StartMs: 60_000, EndMs: 80_000, Kind: "ad"}}} + shadow := signature.NewShadow(l, fakeAudio{"e1": planted(1, ad, 300, 60)}) + + shadow.Run(context.Background(), batch(episode)) + + if len(episode.Breaks) != 1 || episode.Breaks[0].StartMs != 60_000 { + t.Fatal("the shadow run changed the breaks it was given") + } +} |
