summaryrefslogtreecommitdiff
path: root/server
diff options
context:
space:
mode:
Diffstat (limited to 'server')
-rw-r--r--server/internal/signature/landmark.go203
-rw-r--r--server/internal/signature/landmark_test.go185
-rw-r--r--server/internal/signature/library.go252
-rw-r--r--server/internal/signature/library_test.go202
-rw-r--r--server/internal/signature/persist.go151
-rw-r--r--server/internal/signature/persist_test.go96
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))
+ }
+}