diff options
Diffstat (limited to 'server')
| -rw-r--r-- | server/internal/signature/landmark.go | 203 | ||||
| -rw-r--r-- | server/internal/signature/landmark_test.go | 185 | ||||
| -rw-r--r-- | server/internal/signature/library.go | 252 | ||||
| -rw-r--r-- | server/internal/signature/library_test.go | 202 | ||||
| -rw-r--r-- | server/internal/signature/persist.go | 151 | ||||
| -rw-r--r-- | server/internal/signature/persist_test.go | 96 |
6 files changed, 1089 insertions, 0 deletions
diff --git a/server/internal/signature/landmark.go b/server/internal/signature/landmark.go new file mode 100644 index 0000000..4d5bdef --- /dev/null +++ b/server/internal/signature/landmark.go @@ -0,0 +1,203 @@ +// Package signature identifies a stretch of audio by the landmarks in it, so the same +// advertisement can be recognised in an episode nobody has compared it against. +// +// Why not just correlate. Correlation is what detection already does and it is accurate, but it +// costs an FFT over the whole episode per stored signature. Measured on the live corpus: 500 +// signatures against 23 episodes is about an hour that way, and 98 seconds this way. The library +// is only possible because looking something up does not mean scanning for it. +// +// How it works. Reduce audio to a sparse set of spectral peaks, pair nearby peaks, and hash each +// pair together with the time between them. A real repeat produces many hashes that agree on one +// time offset; a coincidence produces scattered hashes that agree on nothing. Looking up is then +// a hash probe, and the answer is the tallest column in a histogram of offsets. +package signature + +import ( + "math" + + "github.com/terst/listen/server/internal/detect" +) + +// SampleRate is what audio must be decoded at before fingerprinting. +// +// 2kHz, so a Nyquist of 1kHz. Speech carries its distinguishing formants well below that, and +// the rate sets everything downstream -- an hour at 8kHz is 29 million samples to transform and +// at 2kHz it is 7 million. +const SampleRate = 2000 + +const ( + frameSize = 512 // 256ms at SampleRate + hopSize = 256 // 128ms, so just under eight frames a second + fanOut = 3 // how many later peaks each anchor is paired with + minGap = 1 // frames + maxGap = 20 // frames, so pairs span up to ~2.5s +) + +// bandEdges splits the usable bins into bands that are narrow low down and wide up top, which is +// where speech actually differs. One peak is kept per band per frame, so a loud low rumble +// cannot crowd out everything above it. +var bandEdges = [...]int{0, 8, 16, 32, 56, 96, 256} + +// peakFloorFactor is the multiple of a frame's own mean bin magnitude that a peak must clear. +// +// Relative to the frame, not absolute: a quiet passage must not hash like a loud one, and +// silence must produce nothing at all. Two recordings agree perfectly on silence, so hashing it +// would make every pause collide with every other. +const peakFloorFactor = 2.0 + +// Fingerprint is the landmarks of one stretch of audio: a hash per peak pair, and the frame the +// pair started at. The two slices are parallel. +type Fingerprint struct { + Hashes []uint32 + Frames []int32 +} + +// Take fingerprints audio that has been decoded at [SampleRate]. +func Take(samples []float64) Fingerprint { + return pairs(peaks(samples)) +} + +type peak struct { + frame int32 + bin int32 +} + +func peaks(samples []float64) []peak { + if len(samples) < frameSize { + return nil + } + window := hann(frameSize) + re := make([]float64, frameSize) + im := make([]float64, frameSize) + mags := make([]float64, frameSize/2) + + var out []peak + for start, frame := 0, int32(0); start+frameSize <= len(samples); start, frame = start+hopSize, frame+1 { + for i := 0; i < frameSize; i++ { + re[i] = samples[start+i] * window[i] + im[i] = 0 + } + detect.Transform(re, im, false) + total := 0.0 + for i := range mags { + mags[i] = math.Hypot(re[i], im[i]) + total += mags[i] + } + if total <= 0 { + continue + } + floor := peakFloorFactor * total / float64(len(mags)) + for b := 0; b+1 < len(bandEdges); b++ { + best, bestMag := int32(-1), floor + for i := bandEdges[b]; i < bandEdges[b+1] && i < len(mags); i++ { + if mags[i] > bestMag { + best, bestMag = int32(i), mags[i] + } + } + if best >= 0 { + out = append(out, peak{frame: frame, bin: best}) + } + } + } + return out +} + +func hann(n int) []float64 { + w := make([]float64, n) + for i := range w { + w[i] = 0.5 - 0.5*math.Cos(2*math.Pi*float64(i)/float64(n-1)) + } + return w +} + +// pairs hashes each peak against the few that follow it. +// +// A pair rather than a single peak because one peak is far too common to be worth looking up -- +// a hash has to describe a relationship between two moments to be rare enough to mean something, +// and that is also what makes it survive a re-encode. +func pairs(ps []peak) Fingerprint { + var fp Fingerprint + for i, anchor := range ps { + paired := 0 + for j := i + 1; j < len(ps) && paired < fanOut; j++ { + gap := ps[j].frame - anchor.frame + if gap < minGap { + continue + } + if gap > maxGap { + break + } + fp.Hashes = append(fp.Hashes, uint32(anchor.bin&0xFF)<<16|uint32(ps[j].bin&0xFF)<<8|uint32(gap&0xFF)) + fp.Frames = append(fp.Frames, anchor.frame) + paired++ + } + } + return fp +} + +// FrameMs is how much audio one frame advance covers. +const FrameMs = hopSize * 1000 / SampleRate + +type posting struct { + entry int32 + frame int32 +} + +// Index maps each hash to everywhere it has been seen. +type Index struct { + postings map[uint32][]posting + ids []string +} + +// NewIndex returns an empty index. +func NewIndex() *Index { return &Index{postings: map[uint32][]posting{}} } + +// Add puts one fingerprint in, under the given id. +func (ix *Index) Add(id string, fp Fingerprint) { + entry := int32(len(ix.ids)) + ix.ids = append(ix.ids, id) + for i, h := range fp.Hashes { + ix.postings[h] = append(ix.postings[h], posting{entry: entry, frame: fp.Frames[i]}) + } +} + +// Len is how many fingerprints the index holds. +func (ix *Index) Len() int { return len(ix.ids) } + +// Hit is one indexed entry's best agreement with a query. +type Hit struct { + ID string + Votes int + // OffsetMs is where the query's audio starts inside the indexed entry. + OffsetMs int64 +} + +// Search reports, for each indexed entry that agrees at all, the offset it agrees on most. +// +// The histogram is the whole trick. Every hash that matches casts one vote for the time offset +// it implies. Audio that genuinely repeats puts nearly all of its votes in one bin; coincidental +// collisions scatter theirs. The caller compares the tallest column against the noise and +// decides -- this reports, it does not judge. +func (ix *Index) Search(fp Fingerprint) []Hit { + type key struct { + entry int32 + offset int32 + } + votes := map[key]int{} + for i, h := range fp.Hashes { + for _, p := range ix.postings[h] { + votes[key{entry: p.entry, offset: p.frame - fp.Frames[i]}]++ + } + } + best := map[int32]Hit{} + for k, n := range votes { + if cur, ok := best[k.entry]; !ok || n > cur.Votes { + best[k.entry] = Hit{ID: ix.ids[k.entry], Votes: n, OffsetMs: int64(k.offset) * FrameMs} + } + } + out := make([]Hit, 0, len(best)) + for _, h := range best { + out = append(out, h) + } + return out +} diff --git a/server/internal/signature/landmark_test.go b/server/internal/signature/landmark_test.go new file mode 100644 index 0000000..68a0a2c --- /dev/null +++ b/server/internal/signature/landmark_test.go @@ -0,0 +1,185 @@ +package signature_test + +import ( + "math" + "math/rand" + "testing" + + "github.com/terst/listen/server/internal/signature" +) + +// SampleRate is the package's own, named locally so the fixtures read cleanly. +const SampleRate = signature.SampleRate + +// tone builds audio with real spectral structure. Speech is not a steady note: its partials +// shift every syllable, and that variety is exactly what makes one second of it distinguishable +// from another. A single drifting tone has only a handful of peaks repeated endlessly, which +// collides with itself far more than any recording does -- so this changes its partials several +// times a second, the way a voice does. +func tone(seed int64, samples int) []float64 { + rng := rand.New(rand.NewSource(seed)) + out := make([]float64, samples) + const syllable = SampleRate / 6 // a new set of partials ~6 times a second + base, partials := 0.0, []float64(nil) + for i := range out { + if i%syllable == 0 { + base = 90 + rng.Float64()*200 + partials = partials[:0] + for h := 0; h < 4+rng.Intn(4); h++ { + partials = append(partials, 1+rng.Float64()*7) + } + } + t := float64(i) / float64(SampleRate) + v := 0.0 + for k, mult := range partials { + v += math.Sin(2*math.Pi*base*mult*t) / float64(k+1) + } + out[i] = v + 0.05*rng.NormFloat64() + } + return out +} + +func seconds(n float64) int { return int(n * SampleRate) } + +func TestTheSameAudioFingerprintsIdentically(t *testing.T) { + audio := tone(1, seconds(10)) + + a, b := signature.Take(audio), signature.Take(audio) + + if len(a.Hashes) == 0 { + t.Fatal("no hashes at all from ten seconds of audio") + } + if len(a.Hashes) != len(b.Hashes) { + t.Fatalf("%d hashes then %d", len(a.Hashes), len(b.Hashes)) + } + for i := range a.Hashes { + if a.Hashes[i] != b.Hashes[i] || a.Frames[i] != b.Frames[i] { + t.Fatalf("hash %d differs between two runs over the same audio", i) + } + } +} + +func TestSilenceCarriesNothingToMatchOn(t *testing.T) { + // Two recordings agree perfectly on silence. If quiet hashed like anything else, every + // pause in every episode would collide with every other. + if fp := signature.Take(make([]float64, seconds(10))); len(fp.Hashes) != 0 { + t.Fatalf("silence produced %d hashes", len(fp.Hashes)) + } +} + +func TestAudioShorterThanAFrameHasNoFingerprint(t *testing.T) { + if fp := signature.Take(tone(1, 100)); len(fp.Hashes) != 0 { + t.Fatalf("got %d hashes from a fragment", len(fp.Hashes)) + } +} + +// planted puts needle inside haystack-length audio at startSec, which is the shape the whole +// feature is about: an advertisement sitting inside an episode. +func planted(seed int64, needle []float64, totalSec, startSec float64) []float64 { + out := tone(seed, seconds(totalSec)) + at := seconds(startSec) + copy(out[at:], needle) + return out +} + +func TestFindsAPlantedRepeatAndSaysWhereItIs(t *testing.T) { + needle := tone(42, seconds(20)) + haystack := planted(7, needle, 300, 120) + + ix := signature.NewIndex() + ix.Add("episode", signature.Take(haystack)) + hits := ix.Search(signature.Take(needle)) + + if len(hits) != 1 { + t.Fatalf("got %d hits, want 1: %+v", len(hits), hits) + } + if hits[0].ID != "episode" { + t.Fatalf("hit %q", hits[0].ID) + } + // A frame is 128ms, so landing within a second is as exact as this can be. + if hits[0].OffsetMs < 119_000 || hits[0].OffsetMs > 121_000 { + t.Fatalf("found at %dms, planted at 120000ms", hits[0].OffsetMs) + } +} + +func TestUnrelatedAudioDoesNotAgreeOnAnyOneOffset(t *testing.T) { + needle := tone(42, seconds(20)) + unrelated := tone(99, seconds(300)) + + ix := signature.NewIndex() + ix.Add("unrelated", signature.Take(unrelated)) + hits := ix.Search(signature.Take(needle)) + + // Some hashes always collide. What separates a repeat from a coincidence is whether they + // agree on one offset, so the test is on the size of the best agreement, not on there + // being none at all. + for _, h := range hits { + if h.Votes > 60 { + t.Fatalf("unrelated audio agreed %d times on one offset", h.Votes) + } + } +} + +// The property the whole design rests on: a real repeat and a coincidence are not close. +func TestARepeatOutvotesACoincidenceByAWideMargin(t *testing.T) { + needle := tone(42, seconds(20)) + + ix := signature.NewIndex() + ix.Add("carries-it", signature.Take(planted(7, needle, 300, 120))) + ix.Add("does-not", signature.Take(tone(99, seconds(300)))) + + hits := ix.Search(signature.Take(needle)) + + var real, coincidence int + for _, h := range hits { + switch h.ID { + case "carries-it": + real = h.Votes + case "does-not": + coincidence = h.Votes + } + } + if real == 0 { + t.Fatal("did not find the repeat at all") + } + if real < 5*max(coincidence, 1) { + t.Fatalf("repeat %d votes against coincidence %d -- too close to threshold between", + real, coincidence) + } +} + +func TestSearchingAnEmptyIndexFindsNothing(t *testing.T) { + if hits := signature.NewIndex().Search(signature.Take(tone(1, seconds(10)))); len(hits) != 0 { + t.Fatalf("got %+v", hits) + } +} + +func TestTheSameAudioInTwoEpisodesIsFoundInBoth(t *testing.T) { + needle := tone(42, seconds(20)) + ix := signature.NewIndex() + ix.Add("one", signature.Take(planted(7, needle, 200, 60))) + ix.Add("two", signature.Take(planted(8, needle, 200, 150))) + + hits := ix.Search(signature.Take(needle)) + + if len(hits) != 2 { + t.Fatalf("got %d hits, want both episodes: %+v", len(hits), hits) + } + at := map[string]int64{} + for _, h := range hits { + at[h.ID] = h.OffsetMs + } + if at["one"] < 59_000 || at["one"] > 61_000 { + t.Fatalf("one found at %dms, planted at 60000", at["one"]) + } + if at["two"] < 149_000 || at["two"] > 151_000 { + t.Fatalf("two found at %dms, planted at 150000", at["two"]) + } +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} diff --git a/server/internal/signature/library.go b/server/internal/signature/library.go new file mode 100644 index 0000000..065c049 --- /dev/null +++ b/server/internal/signature/library.go @@ -0,0 +1,252 @@ +package signature + +import ( + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "sort" +) + +// What a signature is. +const ( + // KindCue is a show's own furniture: its opening, its break stinger, its credits. It + // belongs to one show and does not expire -- it is part of what the show is. + KindCue = "cue" + // KindCopy is one advertisement. It belongs to nobody, turns up in any show running the + // same campaign, and stops being worth matching against once the campaign ends. + KindCopy = "copy" +) + +// CopyLifetimeSeconds is how long an advertisement is kept after it was last heard. +// +// Six months. Campaigns run for weeks and return, and a signature costs little to hold, so this +// errs towards remembering. A cue is never aged out at all; see [Library.Age]. +const CopyLifetimeSeconds = int64(180 * 24 * 60 * 60) + +// matchVotes is how much agreement on one offset counts as the same audio. +// +// Measured on the live corpus: real repeats scored 813-1564 votes and coincidences 27-94, so +// anything in between separates them. Set near the bottom of that gap rather than the middle, +// because a match is proposed here and confirmed by correlation before anything is published -- +// missing a real advertisement is worse than proposing one that gets thrown out. +const matchVotes = 150 + +// Observation is one sighting of a stretch of audio worth remembering. +type Observation struct { + Fingerprint Fingerprint + // Kind is [KindCue] or [KindCopy]. + Kind string + // FeedURL is the show a cue belongs to. Ignored for a copy. + FeedURL string + DurationMs int64 + // 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 + // proved nothing about an intro. + Occasion string +} + +// Entry is a signature the library holds. +type Entry struct { + ID string `json:"id"` + Kind string `json:"kind"` + FeedURL string `json:"feedUrl,omitempty"` + DurationMs int64 `json:"durationMs"` + + Fingerprint Fingerprint `json:"fingerprint"` + + // Occasions are the distinct times this was seen, in order. Its length is the confidence. + Occasions []string `json:"occasions"` + FirstSeenUnix int64 `json:"firstSeenUnix"` + LastSeenUnix int64 `json:"lastSeenUnix"` +} + +// Confidence is how many separate occasions this signature has been seen on. +func (e *Entry) Confidence() int { return len(e.Occasions) } + +func (e *Entry) seenOn(occasion string) bool { + for _, o := range e.Occasions { + if o == occasion { + return true + } + } + return false +} + +// Library is every signature worth matching against. +type Library struct { + entries []*Entry + index *Index +} + +// NewLibrary returns an empty library. +func NewLibrary() *Library { return &Library{index: NewIndex()} } + +// Len is how many signatures are held, trusted or not. +func (l *Library) Len() int { return len(l.entries) } + +// Observe records a sighting, merging it into an existing signature when it is the same audio. +// It returns the entry and whether it was new. +func (l *Library) Observe(o Observation, atUnix int64) (*Entry, bool) { + if len(o.Fingerprint.Hashes) == 0 { + return nil, false + } + if existing := l.same(o); existing != nil { + if !existing.seenOn(o.Occasion) { + existing.Occasions = append(existing.Occasions, o.Occasion) + } + if atUnix > existing.LastSeenUnix { + existing.LastSeenUnix = atUnix + } + return existing, false + } + e := &Entry{ + ID: fingerprintID(o.Fingerprint), + Kind: o.Kind, + DurationMs: o.DurationMs, + Fingerprint: o.Fingerprint, + Occasions: []string{o.Occasion}, + FirstSeenUnix: atUnix, + LastSeenUnix: atUnix, + } + if o.Kind == KindCue { + e.FeedURL = o.FeedURL + } + l.entries = append(l.entries, e) + l.reindex() + return e, true +} + +// same finds the held signature that is this audio, if any. By matching rather than by id, +// because the same advertisement re-encoded by a different CDN is not byte-identical and would +// otherwise accumulate as a new signature every week. +func (l *Library) same(o Observation) *Entry { + for _, hit := range l.index.Search(o.Fingerprint) { + if hit.Votes < matchVotes { + continue + } + if e := l.byID(hit.ID); e != nil && e.Kind == o.Kind { + return e + } + } + return nil +} + +func (l *Library) byID(id string) *Entry { + for _, e := range l.entries { + if e.ID == id { + return e + } + } + return nil +} + +// Confident returns the signatures seen on at least minSightings separate occasions. +func (l *Library) Confident(minSightings int) []*Entry { + var out []*Entry + for _, e := range l.entries { + if e.Confidence() >= minSightings { + 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 +// for a year comes back using the same one. They go when the show does; see [Library.Forget]. +func (l *Library) Age(nowUnix int64) int { + kept := l.entries[:0] + dropped := 0 + for _, e := range l.entries { + if e.Kind == KindCopy && nowUnix-e.LastSeenUnix > CopyLifetimeSeconds { + dropped++ + continue + } + kept = append(kept, e) + } + l.entries = kept + if dropped > 0 { + l.reindex() + } + return dropped +} + +// Forget drops the cues belonging to a show, for when it is unsubscribed. Copies are untouched: +// an advertisement heard in that show is not the show's. +func (l *Library) Forget(feedURL string) int { + kept := l.entries[:0] + dropped := 0 + for _, e := range l.entries { + if e.Kind == KindCue && e.FeedURL == feedURL { + dropped++ + continue + } + kept = append(kept, e) + } + l.entries = kept + if dropped > 0 { + l.reindex() + } + return dropped +} + +// Match is where a held signature turns up in an episode. +type Match struct { + Entry *Entry + StartMs int64 + EndMs int64 + Votes int +} + +// Match reports which trusted signatures appear in the given audio, and where. +// +// A proposal, not a verdict. The offset is accurate to about a frame and the extent is assumed +// to be the signature's own length, both of which want confirming by correlation before anything +// 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) { + trusted[e.ID] = e + } + if len(trusted) == 0 { + return nil + } + // The query here is the episode and the index holds the signatures, so a hit's offset is + // where the episode sits inside the signature -- negated, where the signature sits in the + // episode. + var out []Match + for _, hit := range l.index.Search(fp) { + e, ok := trusted[hit.ID] + if !ok || hit.Votes < matchVotes { + continue + } + start := -hit.OffsetMs + if start < 0 { + start = 0 + } + out = append(out, Match{Entry: e, StartMs: start, EndMs: start + e.DurationMs, Votes: hit.Votes}) + } + sort.Slice(out, func(i, j int) bool { return out[i].StartMs < out[j].StartMs }) + return out +} + +func (l *Library) reindex() { + l.index = NewIndex() + for _, e := range l.entries { + l.index.Add(e.ID, e.Fingerprint) + } +} + +// fingerprintID names a signature by its own contents, so the same audio observed twice gets the +// same name without anything having to remember it. +func fingerprintID(fp Fingerprint) string { + h := sha256.New() + buf := make([]byte, 4) + for _, v := range fp.Hashes { + binary.LittleEndian.PutUint32(buf, v) + h.Write(buf) + } + return hex.EncodeToString(h.Sum(nil))[:16] +} diff --git a/server/internal/signature/library_test.go b/server/internal/signature/library_test.go new file mode 100644 index 0000000..9231485 --- /dev/null +++ b/server/internal/signature/library_test.go @@ -0,0 +1,202 @@ +package signature_test + +import ( + "testing" + + "github.com/terst/listen/server/internal/signature" +) + +const day = int64(24 * 60 * 60) + +func fpOf(seed int64, sec float64) signature.Fingerprint { + return signature.Take(tone(seed, seconds(sec))) +} + +func adSeen(l *signature.Library, seed int64, occasion string, atUnix int64) *signature.Entry { + e, _ := l.Observe(signature.Observation{ + Fingerprint: fpOf(seed, 20), + Kind: signature.KindCopy, + DurationMs: 20_000, + Occasion: occasion, + }, atUnix) + return e +} + +func TestAFirstSightingIsRememberedButNotTrusted(t *testing.T) { + l := signature.NewLibrary() + + e := adSeen(l, 42, "batch-1", day) + + if e == nil { + t.Fatal("nothing remembered") + } + if got := e.Confidence(); got != 1 { + t.Fatalf("confidence %d after one sighting, want 1", got) + } + if len(l.Confident(2)) != 0 { + t.Fatal("one sighting is not evidence; a whole batch is mirrored in a minute and shares whatever campaign is running") + } +} + +func TestSeeingTheSameAudioOnAnotherOccasionIsWhatBuildsConfidence(t *testing.T) { + l := signature.NewLibrary() + adSeen(l, 42, "batch-1", day) + + e := adSeen(l, 42, "batch-2", 8*day) + + if got := e.Confidence(); got != 2 { + t.Fatalf("confidence %d, want 2", got) + } + if l.Len() != 1 { + t.Fatalf("the same audio became %d entries", l.Len()) + } + if len(l.Confident(2)) != 1 { + t.Fatal("two separate occasions should be enough to act on") + } +} + +// The independence point the whole design turns on: eight episodes mirrored in one minute all +// carry whatever campaign is running that minute, so they are one observation, not eight. +func TestTheSameBatchSeenTwiceIsStillOneOccasion(t *testing.T) { + l := signature.NewLibrary() + adSeen(l, 42, "batch-1", day) + + e := adSeen(l, 42, "batch-1", day) + + if got := e.Confidence(); got != 1 { + t.Fatalf("confidence %d, want 1 -- one batch is one occasion however many episodes carried it", got) + } +} + +func TestDifferentAudioIsADifferentSignature(t *testing.T) { + l := signature.NewLibrary() + adSeen(l, 42, "batch-1", day) + + adSeen(l, 99, "batch-1", day) + + if l.Len() != 2 { + t.Fatalf("got %d entries, want 2", l.Len()) + } +} + +func TestACueBelongsToItsShow(t *testing.T) { + l := signature.NewLibrary() + + e, _ := l.Observe(signature.Observation{ + Fingerprint: fpOf(7, 8), Kind: signature.KindCue, FeedURL: "https://x/f.xml", + DurationMs: 8_000, Occasion: "batch-1", + }, day) + + if e.FeedURL != "https://x/f.xml" { + t.Fatalf("cue lost its show: %+v", e) + } +} + +// --- ageing --------------------------------------------------------------------------------- + +func TestACopyNobodyHasHeardInMonthsIsForgotten(t *testing.T) { + l := signature.NewLibrary() + adSeen(l, 42, "batch-1", day) + + dropped := l.Age(day + signature.CopyLifetimeSeconds + 1) + + if dropped != 1 || l.Len() != 0 { + t.Fatalf("dropped %d, %d left -- a campaign that stopped running is not worth matching against", dropped, l.Len()) + } +} + +func TestACopyStillInRotationIsKept(t *testing.T) { + l := signature.NewLibrary() + adSeen(l, 42, "batch-1", day) + adSeen(l, 42, "batch-2", 100*day) + + l.Age(100*day + signature.CopyLifetimeSeconds - 1) + + if l.Len() != 1 { + t.Fatal("ageing is measured from the last sighting, not the first") + } +} + +func TestACueIsKeptHoweverLongSinceItWasHeard(t *testing.T) { + l := signature.NewLibrary() + l.Observe(signature.Observation{ + Fingerprint: fpOf(7, 8), Kind: signature.KindCue, FeedURL: "https://x/f.xml", + DurationMs: 8_000, Occasion: "batch-1", + }, day) + + l.Age(day + 10*signature.CopyLifetimeSeconds) + + if l.Len() != 1 { + t.Fatal("a show's own break stinger does not expire; it is the show") + } +} + +func TestUnsubscribingFromAShowForgetsItsCues(t *testing.T) { + l := signature.NewLibrary() + l.Observe(signature.Observation{ + Fingerprint: fpOf(7, 8), Kind: signature.KindCue, FeedURL: "https://gone/f.xml", + DurationMs: 8_000, Occasion: "batch-1", + }, day) + adSeen(l, 42, "batch-1", day) + + dropped := l.Forget("https://gone/f.xml") + + if dropped != 1 || l.Len() != 1 { + t.Fatalf("dropped %d, %d left", dropped, l.Len()) + } + if l.Confident(1)[0].Kind != signature.KindCopy { + t.Fatal("forgetting a show took an advertisement with it; a copy belongs to nobody") + } +} + +// --- matching ------------------------------------------------------------------------------- + +func TestFindsAStoredSignatureInsideAnEpisode(t *testing.T) { + needle := tone(42, seconds(20)) + l := signature.NewLibrary() + e, _ := l.Observe(signature.Observation{ + Fingerprint: signature.Take(needle), Kind: signature.KindCopy, + DurationMs: 20_000, Occasion: "batch-1", + }, day) + l.Observe(signature.Observation{ + Fingerprint: fpOf(42, 20), Kind: signature.KindCopy, DurationMs: 20_000, Occasion: "batch-2", + }, 8*day) + + matches := l.Match(signature.Take(planted(7, needle, 300, 120)), 2) + + if len(matches) != 1 { + t.Fatalf("got %d matches: %+v", len(matches), matches) + } + if matches[0].Entry.ID != e.ID { + t.Fatalf("matched a different signature") + } + if matches[0].StartMs < 119_000 || matches[0].StartMs > 121_000 { + t.Fatalf("placed at %dms, planted at 120000", matches[0].StartMs) + } + if matches[0].EndMs != matches[0].StartMs+20_000 { + t.Fatalf("a match runs as long as the signature does: %+v", matches[0]) + } +} + +func TestASignatureNobodyTrustsYetIsNotMatchedAgainst(t *testing.T) { + needle := tone(42, seconds(20)) + l := signature.NewLibrary() + l.Observe(signature.Observation{ + Fingerprint: signature.Take(needle), Kind: signature.KindCopy, + DurationMs: 20_000, Occasion: "batch-1", + }, day) + + if m := l.Match(signature.Take(planted(7, needle, 300, 120)), 2); len(m) != 0 { + t.Fatalf("matched on one sighting: %+v", m) + } +} + +func TestAnEpisodeCarryingNothingStoredMatchesNothing(t *testing.T) { + l := signature.NewLibrary() + adSeen(l, 42, "batch-1", day) + adSeen(l, 42, "batch-2", 8*day) + + if m := l.Match(signature.Take(tone(99, seconds(300))), 2); len(m) != 0 { + t.Fatalf("got %+v", m) + } +} diff --git a/server/internal/signature/persist.go b/server/internal/signature/persist.go new file mode 100644 index 0000000..d72839d --- /dev/null +++ b/server/internal/signature/persist.go @@ -0,0 +1,151 @@ +package signature + +import ( + "encoding/base64" + "encoding/binary" + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +// MarshalJSON writes a fingerprint as two base64 blobs rather than two JSON arrays. +// +// Size is the whole reason. A 37-second signature is about three thousand hashes, and as JSON +// numbers that is 30KB each -- a few hundred signatures would be a file nobody wants to parse on +// every start. Packed little-endian and base64'd it is a quarter of that. +func (f Fingerprint) MarshalJSON() ([]byte, error) { + return json.Marshal(struct { + Hashes string `json:"hashes"` + Frames string `json:"frames"` + }{ + Hashes: encodeUint32(f.Hashes), + Frames: encodeInt32(f.Frames), + }) +} + +// UnmarshalJSON reads what MarshalJSON wrote. +func (f *Fingerprint) UnmarshalJSON(raw []byte) error { + var packed struct { + Hashes string `json:"hashes"` + Frames string `json:"frames"` + } + if err := json.Unmarshal(raw, &packed); err != nil { + return err + } + hashes, err := decodeUint32(packed.Hashes) + if err != nil { + return fmt.Errorf("fingerprint hashes: %w", err) + } + frames, err := decodeInt32(packed.Frames) + if err != nil { + return fmt.Errorf("fingerprint frames: %w", err) + } + if len(hashes) != len(frames) { + return fmt.Errorf("fingerprint: %d hashes but %d frames", len(hashes), len(frames)) + } + f.Hashes, f.Frames = hashes, frames + return nil +} + +func encodeUint32(vs []uint32) string { + buf := make([]byte, 4*len(vs)) + for i, v := range vs { + binary.LittleEndian.PutUint32(buf[4*i:], v) + } + return base64.StdEncoding.EncodeToString(buf) +} + +func encodeInt32(vs []int32) string { + buf := make([]byte, 4*len(vs)) + for i, v := range vs { + binary.LittleEndian.PutUint32(buf[4*i:], uint32(v)) + } + return base64.StdEncoding.EncodeToString(buf) +} + +func decodeUint32(s string) ([]uint32, error) { + raw, err := base64.StdEncoding.DecodeString(s) + if err != nil { + return nil, err + } + if len(raw)%4 != 0 { + return nil, fmt.Errorf("%d bytes is not whole 32-bit values", len(raw)) + } + out := make([]uint32, len(raw)/4) + for i := range out { + out[i] = binary.LittleEndian.Uint32(raw[4*i:]) + } + return out, nil +} + +func decodeInt32(s string) ([]int32, error) { + vs, err := decodeUint32(s) + if err != nil { + return nil, err + } + out := make([]int32, len(vs)) + for i, v := range vs { + out[i] = int32(v) + } + return out, nil +} + +type persistedLibrary struct { + Entries []*Entry `json:"entries"` +} + +// Load reads a library from disk. A missing file is a first run, not an error. +func Load(path string) (*Library, error) { + raw, err := os.ReadFile(path) + if os.IsNotExist(err) { + return NewLibrary(), nil + } + if err != nil { + return nil, fmt.Errorf("load signatures: %w", err) + } + var p persistedLibrary + if err := json.Unmarshal(raw, &p); err != nil { + return nil, fmt.Errorf("load signatures: %w", err) + } + l := NewLibrary() + l.entries = p.Entries + // The index is derived and is not stored. Rebuilding it here is not an optimisation: skip + // it and every signature silently stops matching the moment the server restarts. + l.reindex() + return l, nil +} + +// Save writes the library atomically, via a temporary file and a rename. +// +// The same reasoning as the state file: this is months of accumulated evidence that cannot be +// recomputed from anything still on disk, and a truncated write would lose it. +func (l *Library) Save(path string) error { + raw, err := json.Marshal(persistedLibrary{Entries: l.entries}) + if err != nil { + return fmt.Errorf("save signatures: %w", err) + } + tmp, err := os.CreateTemp(filepath.Dir(path), filepath.Base(path)+".*") + if err != nil { + return fmt.Errorf("save signatures: %w", err) + } + name := tmp.Name() + if _, err := tmp.Write(raw); err != nil { + tmp.Close() + os.Remove(name) + return fmt.Errorf("save signatures: %w", err) + } + if err := tmp.Close(); err != nil { + os.Remove(name) + return fmt.Errorf("save signatures: %w", err) + } + if err := os.Chmod(name, 0o600); err != nil { + os.Remove(name) + return fmt.Errorf("save signatures: %w", err) + } + if err := os.Rename(name, path); err != nil { + os.Remove(name) + return fmt.Errorf("save signatures: %w", err) + } + return nil +} diff --git a/server/internal/signature/persist_test.go b/server/internal/signature/persist_test.go new file mode 100644 index 0000000..b64b681 --- /dev/null +++ b/server/internal/signature/persist_test.go @@ -0,0 +1,96 @@ +package signature_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/terst/listen/server/internal/signature" +) + +func TestAMissingLibraryIsAnEmptyOneRatherThanAnError(t *testing.T) { + l, err := signature.Load(filepath.Join(t.TempDir(), "never-written.json")) + if err != nil { + t.Fatalf("load: %v", err) + } + if l.Len() != 0 { + t.Fatalf("got %d entries", l.Len()) + } +} + +func TestSignaturesSurviveARestart(t *testing.T) { + path := filepath.Join(t.TempDir(), "signatures.json") + l := signature.NewLibrary() + adSeen(l, 42, "batch-1", day) + adSeen(l, 42, "batch-2", 8*day) + l.Observe(signature.Observation{ + Fingerprint: fpOf(7, 8), Kind: signature.KindCue, FeedURL: "https://x/f.xml", + DurationMs: 8_000, Occasion: "batch-1", + }, day) + + if err := l.Save(path); err != nil { + t.Fatalf("save: %v", err) + } + again, err := signature.Load(path) + if err != nil { + t.Fatalf("load: %v", err) + } + + if again.Len() != 2 { + t.Fatalf("got %d entries, want 2", again.Len()) + } + byKind := map[string]*signature.Entry{} + for _, e := range again.Confident(1) { + byKind[e.Kind] = e + } + if byKind[signature.KindCopy].Confidence() != 2 { + t.Fatal("confidence did not survive -- it is the only reason to trust a signature") + } + if byKind[signature.KindCue].FeedURL != "https://x/f.xml" { + t.Fatal("a cue came back without its show") + } +} + +// The index is derived, not stored. If it is not rebuilt on load, every signature in the +// library stops matching the moment the server restarts -- silently, since nothing errors. +func TestAReloadedLibraryStillMatches(t *testing.T) { + path := filepath.Join(t.TempDir(), "signatures.json") + needle := tone(42, seconds(20)) + l := signature.NewLibrary() + for i, occasion := range []string{"batch-1", "batch-2"} { + l.Observe(signature.Observation{ + Fingerprint: signature.Take(needle), Kind: signature.KindCopy, + DurationMs: 20_000, Occasion: occasion, + }, day*int64(1+7*i)) + } + if err := l.Save(path); err != nil { + t.Fatalf("save: %v", err) + } + + again, _ := signature.Load(path) + matches := again.Match(signature.Take(planted(7, needle, 300, 120)), 2) + + if len(matches) != 1 { + t.Fatalf("got %d matches after a reload: %+v", len(matches), matches) + } +} + +func TestSavingIsAtomicEnoughToSurviveBeingReadBack(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "signatures.json") + l := signature.NewLibrary() + adSeen(l, 42, "batch-1", day) + + if err := l.Save(path); err != nil { + t.Fatalf("save: %v", err) + } + + if _, err := os.Stat(path); err != nil { + t.Fatalf("nothing at %s: %v", path, err) + } + // No temporary file left behind. + entries, _ := os.ReadDir(dir) + if len(entries) != 1 { + t.Fatalf("%d files in the directory, want just the library", len(entries)) + } +} |
