summaryrefslogtreecommitdiff
path: root/server/cmd
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-10-05 08:57:59 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-10-05 08:57:59 +0000
commit7cc88394458e9d03e3a6a6e58cdff9c8dd4ad7a3 (patch)
tree7e9980f718b10c49895d1df673001e058f2af117 /server/cmd
parent4fb118b2db32f269c5aeb217644dc5e80640c885 (diff)
Find why Tier 3 never confirmed, and port detection to Go
Peter's report: the phone runs hot, uses a lot of battery, playback takes a moment to start, and intelligent skipping has never done anything. **The hot phone and the dead detection were one bug.** The candidate search correlated 117 window positions against the whole of the other clip, re-transforming that unchanged clip every time -- 199 seconds per episode end, measured on a desktop, so minutes more on a phone. FingerprintWorker is a plain WorkManager worker, Android stops one at ten minutes, and nothing was persisted until a comparison returned. So it was killed mid-flight, saved nothing, and was re-enqueued to start over, forever. IntroOutroDetectorScaleTest is the tripwire whose absence allowed this: the unit tests ran on 300-sample clips while production ran on 1,920,000. Fixed coarse-to-fine, plus AudioCorrelation.prepareTarget so the target clip is transformed once per search rather than 117 times. 10.6x, and a test asserts the chosen reference window is byte-identical to what a full-resolution search picks. **Midroll detection could never have run at all.** decodeFull buffered a whole episode at its native rate -- 434MB for a 13-minute episode, no largeHeap -- to produce 6MB of 8kHz mono. It threw OutOfMemoryError into a swallowing runCatching and the episode was then marked checked forever. MonoResampler now streams the decode so the peak allocation is the output, and maxDecodableMs skips a longer episode before spending anything and without marking it checked. **Detection is moving to a server**, Peter's call, in Go to match podkit and doot. server/ holds the ported core, verified three ways because this is the second port of this maths and the first shipped a normalization bug: numpy's literals extracted mechanically from AudioCorrelationTest, the FFT against a direct DFT, and the whole correlation path against a brute-force Pearson. On four real Radiolab episodes it confirms the intro on 4 of 4, at 48.0s, 8.65s, 8.65s and 65.15s -- the varying pre-roll, observed in working code at last. skips.SelfCheck verifies located segments against *each other* rather than against the reference, which is a different claim: correlation is not transitive, so two segments can each clear 0.6 against a reference and be unrelated. It rejected the outro on its first real run (r=0.746), withholding offsets that would have ended three episodes early. Offsets are published only when they verify. Also: the episode screen gets a working scrubber with ad-break marks and a declared-versus-measured length readout, queue rows open the episode instead of doing nothing, PrepareTrace logs playback-start timing against episode length for the still-open pause question, and -PphoneOnly drops the emulator's CPython from a sideload build (114MB -> 99MB). Full history and numbers in docs/detection-findings.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
Diffstat (limited to 'server/cmd')
-rw-r--r--server/cmd/listen-detect/main.go119
1 files changed, 119 insertions, 0 deletions
diff --git a/server/cmd/listen-detect/main.go b/server/cmd/listen-detect/main.go
new file mode 100644
index 0000000..49394fd
--- /dev/null
+++ b/server/cmd/listen-detect/main.go
@@ -0,0 +1,119 @@
+// Command listen-detect reports the recurring intro and outro across a show's episode files.
+//
+// The detection core of the server, usable on its own so a result can be checked against real
+// audio rather than only against synthetic fixtures. Point it at several episodes of one show:
+//
+// listen-detect episode1.mp3 episode2.mp3 episode3.mp3 episode4.mp3
+package main
+
+import (
+ "context"
+ "flag"
+ "fmt"
+ "os"
+ "path/filepath"
+ "time"
+
+ "github.com/terst/listen/server/internal/decode"
+ "github.com/terst/listen/server/internal/skips"
+)
+
+func main() {
+ clipSeconds := flag.Int("clip-seconds", skips.DefaultClipSeconds, "window searched at each end of an episode")
+ referenceSeconds := flag.Int("reference-seconds", skips.DefaultReferenceSeconds, "length of the segment looked for")
+ threshold := flag.Int("confirm", skips.DefaultConfirmThreshold, "episodes that must agree (lowering this defeats the safety mechanism)")
+ coarse := flag.Int("coarse", 0, "decimation for the candidate search; 0 uses the default, 1 searches at full resolution")
+ flag.Parse()
+
+ files := flag.Args()
+ if len(files) == 0 {
+ fmt.Fprintln(os.Stderr, "usage: listen-detect [flags] <episode file>...")
+ flag.PrintDefaults()
+ os.Exit(2)
+ }
+
+ cfg := skips.DefaultConfig()
+ cfg.ClipSeconds = *clipSeconds
+ cfg.ReferenceSeconds = *referenceSeconds
+ cfg.ConfirmThreshold = *threshold
+ if *coarse > 0 {
+ cfg.Tuning.CoarseFactor = *coarse
+ }
+
+ episodes := make([]skips.Episode, 0, len(files))
+ for _, f := range files {
+ episodes = append(episodes, skips.Episode{ID: filepath.Base(f), Path: f})
+ }
+
+ decoder := decode.Decoder{}
+ ctx := context.Background()
+
+ fmt.Printf("%d episodes, %ds windows, %ds reference, confirm on %d, coarse factor %d\n\n",
+ len(episodes), cfg.ClipSeconds, cfg.ReferenceSeconds, cfg.ConfirmThreshold, cfg.Tuning.CoarseFactor)
+
+ for _, ep := range episodes {
+ ms, err := decoder.DurationMs(ctx, ep.Path)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "%s: %v\n", ep.ID, err)
+ os.Exit(1)
+ }
+ fmt.Printf(" %-44s %s\n", ep.ID, clock(ms))
+ }
+
+ started := time.Now()
+ result, err := skips.Detect(ctx, decoder, episodes, cfg)
+ if err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+ elapsed := time.Since(started)
+
+ fmt.Printf("\nintro: confirmed on %d of %d episodes%s\n",
+ result.IntroConfirmedOn, len(episodes), verdict(result.IntroCheck))
+ fmt.Printf("outro: confirmed on %d of %d episodes%s\n",
+ result.OutroConfirmedOn, len(episodes), verdict(result.OutroCheck))
+ fmt.Printf("detection took %s\n\n", elapsed.Round(time.Millisecond))
+
+ fmt.Printf(" %-44s %-14s %s\n", "episode", "skip intro to", "end early by")
+ for _, o := range result.Offsets {
+ fmt.Printf(" %-44s %-14s %s\n", o.EpisodeID, optional(o.IntroMs), optional(o.OutroMs))
+ }
+
+ // Non-zero when a confirmation could not verify itself, so this is usable as a check and
+ // not only as a report.
+ if !result.IntroCheck.OK || !result.OutroCheck.OK {
+ fmt.Fprintln(os.Stderr, "\nat least one confirmation failed its self-check; no offsets published for it")
+ os.Exit(1)
+ }
+}
+
+// verdict describes whether the located segments agreed with each other -- a different and
+// stricter question than the detection threshold, and the one that says the answer is real.
+func verdict(r skips.CheckReport) string {
+ if r.Pairs == 0 {
+ return ""
+ }
+ if r.OK {
+ return fmt.Sprintf("; self-check passed on %d pairs, worst r=%.4f", r.Pairs, r.WorstScore)
+ }
+ return fmt.Sprintf("; SELF-CHECK FAILED: %s vs %s at r=%.4f -- offsets withheld",
+ r.WorstA, r.WorstB, r.WorstScore)
+}
+
+func optional(ms *int64) string {
+ if ms == nil {
+ return "-"
+ }
+ return clock(*ms)
+}
+
+// clock renders a duration to the millisecond. Not to the nearest second: an offset read back
+// at second precision is out by up to half a second, which is enough to make the same audio
+// look like different audio, and that cost real time the first time this was checked by hand.
+func clock(ms int64) string {
+ total := ms / 1000
+ if h := total / 3600; h > 0 {
+ return fmt.Sprintf("%d:%02d:%02d.%03d", h, total/60%60, total%60, ms%1000)
+ }
+ return fmt.Sprintf("%d:%02d.%03d", total/60, total%60, ms%1000)
+}