diff options
Diffstat (limited to 'docs')
| -rw-r--r-- | docs/detection-findings.md | 223 |
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. |
