diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-10-24 09:17:21 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-10-24 09:17:21 +0000 |
| commit | 868d182ebe0979f0293b8e3b8bf068354b3eb698 (patch) | |
| tree | 45017ed92812f07cee3ddd6146b79ca5e9febe70 | |
| parent | 34e50c04b18313d92e3b3a565965675fd8b50b8a (diff) | |
Store signatures, and age the ones that stop mattering
The spike proved landmark hashing separates a real repeat from a
coincidence; this is the library it unlocks. Rebuilt test-first rather
than lifted from the throwaway harness, against a fixture whose
partials shift several times a second -- a steady tone has only a
handful of peaks repeated endlessly and collides with itself far more
than any recording does, so it would have flattered the design.
Two kinds, which behave nothing alike. A cue is a show's own
furniture, belongs to one feed, and never expires: a break stinger is
not a campaign, and a show that goes quiet for a year comes back using
the same one. It goes when the subscription does. A copy is one
advertisement, belongs to nobody, and is forgotten six months after it
was last heard.
Confidence is a count of separate occasions, and a whole batch of
episodes mirrored in one minute is one occasion however many of them
carried the audio. That is the same independence problem that made
eight agreeing episodes prove nothing about an intro -- they all ran
whatever campaign was running that minute. Nothing is matched against
until it has been seen twice.
Signatures merge by matching rather than by identity, because the same
advertisement re-encoded by another CDN is not byte-identical and
would otherwise pile up as a new signature every week.
Stored as packed base64 rather than JSON arrays: three thousand hashes
per signature is 30KB as numbers and a quarter of that packed, and
this file gets parsed on every start. The index is deliberately not
stored, and is rebuilt on load -- skip that and every signature
silently stops matching after a restart.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
| -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)) + } +} |
