summaryrefslogtreecommitdiff
path: root/docs
diff options
context:
space:
mode:
Diffstat (limited to 'docs')
-rw-r--r--docs/detection-findings.md223
1 files changed, 223 insertions, 0 deletions
diff --git a/docs/detection-findings.md b/docs/detection-findings.md
index 6c7385d..1bb7880 100644
--- a/docs/detection-findings.md
+++ b/docs/detection-findings.md
@@ -272,3 +272,226 @@ instead of restarting again.
YouTube extraction and resolution **cannot be exercised from tungsten** — every watch URL gets a
302 to a bot-check page. Anything touching YouTube has to be verified on the phone. Podcast
feeds, Room migrations, playback and the fingerprint pipeline are all fine on the emulator.
+
+---
+
+## Fifth pass, 2026-10-03: the cost nobody had measured
+
+**Peter's report:** the phone runs hot and uses a lot of battery, and intelligent skipping has
+never done anything to any episode.
+
+Everything above is still true and the retention fix still stands. It was not the whole story,
+and on the evidence below it was not the dominant blocker either.
+
+### The candidate search cost 199 seconds per end, measured
+
+`IntroOutroDetector.findCandidate` bootstrapped a show by sliding an 8-second window across a
+240-second clip in quarter-window steps -- **117 positions** -- and running a *full-length* FFT
+cross-correlation against the whole of the other clip at every one of them, re-transforming that
+unchanged clip each time. 702 transforms of 2^21 points per episode.
+
+Measured, not estimated, by `IntroOutroDetectorScaleTest` at the real sizes and real constants:
+
+| path | before | after |
+| --- | --- | --- |
+| candidate search, one end | **199.1 s** | 17.8 s |
+| ditto, full resolution with the target transform hoisted | 136.6 s | -- |
+| confirming against an existing reference | 1.7 s | 1.7 s |
+
+That is on a desktop. A phone has a fraction of this machine's memory bandwidth.
+
+### Why that meant nothing was ever persisted
+
+`FingerprintWorker` is a plain WorkManager worker, so **Android stops it at about ten minutes**.
+Both ends of an episode run inside one worker, and `FingerprintUpdater` writes nothing until a
+comparison returns -- so the worker was killed mid-correlation, saved nothing, and WorkManager
+re-enqueued it to start the same work again with backoff.
+
+The expensive path is specifically the *bootstrap*, the one that runs until a show has a
+reference clip. Escaping it requires one run to finish. None ever did. This is a complete
+explanation of both of Peter's reports at once: hours of full-CPU work, and a
+`show_fingerprints` row frozen at the first episode ever processed.
+
+It also fits the second pass's observation better than the retention theory does. That pass found
+Up First's row frozen even though a second episode "was downloaded, played and decoded
+successfully (by a different worker)", and concluded the comparison "never ran at all". On this
+evidence the comparison *did* run, for ten minutes, and died before its `upsert`. Both mechanisms
+produce a frozen row; only one of them also produces a hot phone.
+
+**One data point still does not fit:** Cruising the Waves reached `confirmedCount=2` that day, so
+something completed once. Worth checking whether its episodes are short enough to have come in
+under the limit.
+
+### The fix
+
+Coarse-to-fine, the composition `MidrollUpdater` already used and this document already
+defended: decimate both clips by 8, rank candidate positions on the decimated copies, then
+re-slice the best three at native resolution and let a full-resolution correlation decide.
+Plus `AudioCorrelation.prepareTarget`, so the target clip is transformed once per search rather
+than 117 times.
+
+**10.6x faster, and provably the same answer.** `the coarse search picks the same segment full
+resolution does` asserts the chosen reference window is identical sample for sample -- both
+searches step the same native grid, so the positions are directly comparable. No detection
+threshold was touched.
+
+### Midroll detection could never have run at all
+
+Separately and independently: `MediaCodecClipSource.decodeFull` buffered every interleaved sample
+at the file's native rate, then every mono frame, and only then resampled to 8kHz.
+
+| episode | native PCM buffer | mono buffer | both live at once |
+| --- | --- | --- | --- |
+| 10m | 106 MB | 212 MB | 318 MB |
+| 13m (Up First) | 145 MB | 289 MB | 434 MB |
+| 30m | 308 MB | 616 MB | 924 MB |
+| 60m | 611 MB | 1,221 MB | 1,832 MB |
+
+There is no `largeHeap` in the manifest. It threw `OutOfMemoryError`, the `runCatching` in
+`decode` swallowed it, `decodeFull` returned null, and `MidrollUpdater` then marked the episode
+**checked forever**. So no episode of any normal length was ever scanned, none would be retried,
+and the readout said "None found" rather than anything true.
+
+Two changes: `MonoResampler` consumes the decoder buffer by buffer and keeps only the 8kHz
+output, so the peak allocation is the result rather than the input; and `MidrollUpdater.Tuning`
+now has a `maxDecodableMs` (20 minutes) that skips a longer episode **before** spending anything
+and **without** marking it checked.
+
+**Still outstanding:** an episode over that ceiling is not scanned. Lifting it properly means
+decoding candidate ranges on demand (`decodeRange`) instead of holding the whole body, which is
+a real change to `MidrollUpdater.discover`/`extractSegment` and to `MidrollDetector.check` --
+all three currently want the entire native-resolution array. Left deliberately undone rather
+than half-done.
+
+---
+
+## The playback-start pause: one hypothesis disproven, instrumentation shipped
+
+Peter's third report: "there's a longish pause when starting playback, proportional to episode
+length." Recorded here with the others because the next session should not re-check what has
+already been eliminated.
+
+**Nothing in the app's own play path scales with episode length.** Traced end to end: tap ->
+`PlayerViewModel.play` -> `PlaybackAdvancer.start` (a few indexed row reads) ->
+`PlayerConnection.play` -> `setMediaItem`/`prepare`. No file scan, no hashing, no probe.
+
+**`PrepareTrace` now measures it**, tagged `ListenPlayback`, one line per start:
+
+```
+prepare ep=9c9750ef len=1642s src=local ready=666ms playing=667ms
+```
+
+**Measured on the emulator, a 27-minute downloaded Radiolab episode: ~400-500ms to READY**, with
+no other load on the device. So there is a real cost here and it is not only CPU starvation from
+the fingerprint worker.
+
+### Disproven: the missing MP3 seek table
+
+The downloaded file has **no Xing, Info or VBRI header** (checked directly: ID3 tag of 166 bytes,
+audio from byte 176, none of the three markers present). Without one, Media3 has no seek table to
+read, and the obvious suspicion was that `Mp3Extractor` was reading through the file to build one
+-- which would be length-proportional, exactly matching the report.
+
+Tested by enabling `Mp3Extractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING` on a
+`DefaultExtractorsFactory` and measuring the same episode:
+
+| configuration | ready (ms) | mean |
+| --- | --- | --- |
+| shipped default | 404, 531, 386 | ~440 |
+| constant-bitrate seeking on | 469, 450, 404 | ~441 |
+
+**No difference.** The distributions overlap entirely. A first pair of samples had looked like
+666ms against 411ms, which is a 38% improvement and was cold-start noise -- the lesson being that
+one sample of each proves nothing here, because the spread is wider than any effect.
+
+The flag was reverted. It would also have been the wrong trade even if it had worked: constant-
+bitrate seeking makes seeks approximate on a genuinely VBR file, and playback position is the one
+piece of data in this app that cannot be reconstructed.
+
+### What is still open
+
+Whether the ~440ms scales with episode length. It cannot be settled on the emulator, which has
+one episode on it. The instrumented build is on the phone, so **two plays of episodes with very
+different lengths will answer it from the log** -- compare `len=` against `ready=`. If it is flat,
+the pause is a fixed preparation cost and the "proportional" impression was the fingerprint
+worker saturating the CPU, which is now fixed. If it scales, the cause is inside Media3's
+preparation and the next place to look is what `ProgressiveMediaPeriod` reads before reporting
+READY.
+
+---
+
+## 2026-10-05: detection ported to Go and verified on real audio
+
+Peter's call: detection moves to a server, in Go, matching podkit and doot. The port is in
+`server/`, and this section is what it actually proves -- which is not the same as what it is.
+
+### Verified
+
+**Against numpy, not against itself.** `AudioCorrelationTest`'s numpy-derived fixtures were
+extracted mechanically (not retyped) into `server/internal/detect/fixtures_test.go`, with the
+same pinned literals: self-match 1.0, embedded sting r=0.9998959789695058 at position 120,
+unrelated target r=0.3672490793439344. The Go FFT is separately checked against a direct DFT
+computed from the definition, and the whole FFT correlation path against a brute-force Pearson
+correlation. That is three independent anchors, because this is the second port of this maths
+and the first one shipped a normalization bug.
+
+**On real audio, end to end.** Four Radiolab episodes, fetched from the live feed:
+
+```
+intro: confirmed on 4 of 4 episodes; self-check passed on 6 pairs, worst r=0.9286
+ ep1 21:07 skip intro to 0:56.000
+ ep2 22:05 skip intro to 0:16.652
+ ep3 31:02 skip intro to 0:16.652
+ ep4 1:03:27 skip intro to 1:13.152
+detection took 2m9s
+```
+
+The intro sits at a different offset in each episode -- 48.0s, 8.65s, 8.65s, 65.15s -- which is
+the varying pre-roll this document has described since the first pass, now observed directly in
+production code. Cross-checking the located segments against each other at exact sample
+positions gives 0.93 to 0.998 on all six pairs: it is genuinely the same recording in all four.
+
+### What the self-check caught, immediately
+
+`skips.SelfCheck` correlates the *located segments against one another*, rather than each
+against the reference they were found with. Those are different claims. Correlation is not
+transitive: two segments can each clear 0.6 against a reference while scoring near zero against
+each other, and offsets derived from such a set would cut different audio out of different
+episodes.
+
+On the very first real run it rejected the outro:
+
+```
+outro: confirmed on 3 of 4 episodes; SELF-CHECK FAILED: ep1 vs ep2 at r=0.7460 -- offsets withheld
+```
+
+Before the check existed, that same run published outro offsets of 0:12, 0:11 and 0:11 -- which
+would have ended three episodes early at a point two of them do not share. **The confirmation
+threshold passed a false positive and the self-check stopped it.** Offsets are now only
+published when the segments verify against each other, and `listen-detect` exits non-zero when
+they do not.
+
+Outro detection on this show is therefore still unproven. It fails closed, which is the
+important part, but why a 0.746 pair cleared a 0.6 threshold is the next thing to look at.
+
+### A false alarm worth not repeating
+
+The first hand check of the real run showed r=0.03 between two located segments and looked like
+a serious bug. It was an artifact of reading offsets the CLI had rounded to whole seconds: the
+true position was 8.65s, so the extracted window was 0.65s out, while two episodes that happened
+to share the same rounded offset lined up perfectly and scored 0.997. The CLI now prints
+milliseconds, and `SelfCheck` tolerates half a second of position error for the same reason.
+
+### Not verified, and not built
+
+- **No server.** What exists is the detection core and a CLI. The feed poller, the episode
+ mirror, the 8-episode retention, the HTTP API and the phone changes are all still to come.
+- **Dynamic ad insertion did not reproduce from this host.** All four episodes served *exactly*
+ their declared length -- Grass Apes at 22:05, against the 29:34 and 27:39 measured earlier
+ from the phone. So either insertion is conditioned on the client (user agent, geography,
+ headers) or it is not always on. This matters in both directions: it is the entire reason the
+ server has to serve the audio it detected against, and if the server reliably gets the clean
+ file then mirroring strips the advertising rather than merely locating it. Four samples from
+ one host with curl is not enough to conclude either way, and it should be measured properly
+ before the design leans on it.
+- **Midroll detection is not ported.** Only intro/outro.