diff options
34 files changed, 125 insertions, 3217 deletions
diff --git a/.coverage-baseline b/.coverage-baseline index da45132..e04ecf2 100644 --- a/.coverage-baseline +++ b/.coverage-baseline @@ -1 +1 @@ -95.35 +94.58 @@ -146,45 +146,40 @@ downloaded at once, Facebook videos are supported, the library ranks shows by th and opt-in skipping of breaks the publisher marked `toc: false`. Nothing is guessed from chapter titles. -**Tier 3 skip detection — audio fingerprinting — is built, in two parts.** Both were desktop- -prototyped against real episodes before any Android code, per the design plan's own instruction. - -- **Intro/outro** (`IntroOutroDetector`, `AudioCorrelation`, `MediaCodecClipSource`, - `FingerprintUpdater`): one fixed candidate per show per end, needing 3+ independently-fetched - episodes to agree before it is trusted -- the confirmation threshold exists because two episodes - fetched close together can agree on a segment that is a dynamically-inserted ad, not the show's - real intro (found prototyping against Darknet Diaries). Triggered per download via - `FingerprintWorker`. -- **Midroll ad breaks** (`MidrollScanner`, `MidrollDetector`, `MidrollUpdater`): a show can have - several distinct midroll breaks, at a different timestamp in every episode, so this scans a - whole episode body rather than a fixed head/tail window -- `MidrollScanner.findSharedSegments` - finds every segment two episodes share, at whatever position each happens to have it, and - `MidrollUpdater` composes a coarse (decimated) pass with a native-resolution verify so that scan - is fast enough to run on a phone. Prototyping this against Darknet Diaries found a real recurring - segment (not the fixed intro) independently present across episodes nine years apart. Runs - nightly while charging (`MidrollWorker`), not per-download: a full-body scan is too expensive for - that, and unlike a download it never touches the network. No per-show toggle, same as - intro/outro -- the confirmation threshold is the safety mechanism. - -**Both halves have now run on a device**, and doing so found a blocking defect the unit tests -could never have caught. `show_fingerprints.pendingClipPcm` held a whole decoded head/tail -window -- 240 seconds at 8kHz is 3.84MB as 16-bit PCM -- and Android reads every Room row -through a CursorWindow, which refuses a row over 2MB *on the way out*. The insert succeeded; -the next read threw `SQLiteBlobTooBigException`. Intro/outro detection therefore crashed the -app the moment a show's second episode was fingerprinted, and could never have worked as -shipped. Fixed in migration 9 -> 10 by storing only `pendingEpisodeId` and re-decoding on -demand -- the pattern `midrollPendingEpisodeId` already used for the same reason. - -`StoredClip` now owns that limit, `MidrollUpdater` caps a candidate segment at 60 seconds -before persisting it (a long sponsor block would otherwise hit the same wall), and -`StoredClipSizeTest` pins both against the limit so the class of bug cannot come back quietly. - -What is proven on hardware: the MediaCodec decode loop runs, `FingerprintWorker` completes -SUCCESS per download, and the re-decode path is exercised. What is **not** yet proven is that -detection actually confirms anything -- three downloaded Up First episodes left both ends still -"Nothing found yet". That may be correct (the confirmation threshold is deliberately strict) or -it may mean the 8-second reference window is too wide for a ~6-second sting; the desktop -prototype is the thing to compare against, and nobody has. +**Tier 3 skip detection — audio fingerprinting — was built on the phone, and is gone from it.** +Deleted 2026-11-04, after `ReachabilityTest` was widened to classes and found that none of it had +been reachable since detection moved to the server: `FingerprintWorker` and `MidrollWorker` went +then, and the twelve classes they drove stayed, shipped in every APK. + +What went: `IntroOutroDetector`, `AudioCorrelation`, `MidrollScanner`, `MidrollDetector`, +`MidrollUpdater`, `FingerprintUpdater`, `MediaCodecClipSource`, `Decimate`, `Fft`, `MonoResampler`, +`PcmCodec`, `StoredClip` and 67 unit tests. **The tag `on-device-detection` is the last commit +that has it** — `git checkout on-device-detection -- app/src/main/java/org/terst/listen/fingerprint/` +brings it all back, and the detection it implemented now lives in `server/internal/skips` and +`server/internal/signature`, prototyped and measured against real audio there instead. + +What stayed, and why, because none of it is detection: `SkipSummary` words the readout, +`BreakTrack` and `BreakPopup` draw and explain the marks on the episode timeline, and +`MidrollClipStore` shrank to `deleteAllFor` alone — a phone that ran the old version still has +clip files on disk, and unsubscribing is the only thing that clears them. + +**The database was deliberately not touched.** `show_fingerprints`, `show_midrolls` and +`episode_midrolls` are the server's columns now — `ServerSkips` writes its answers into exactly the +columns the on-device detector used to, which is why playback needed no changes when detection +moved. No migration, no schema change, nothing for a real library to go wrong over. + +Two things the deletion left standing that a future session should look at rather than assume: + +- **The per-show detection readout has nothing feeding it.** `ShowSettingsViewModel.loadDetection` + reads `ShowFingerprintDao.get` and `MidrollDao.getCandidates`, and nothing has written a + *candidate* row since detection moved: the server writes per-episode ranges and offsets, not the + confirmation state the readout describes. So "2 of 3 episodes agree" can never appear, and a + phone that never ran the old version shows "nothing found yet" for every show while the server + is finding and publishing breaks. Predates this deletion; it is the same migration's doing. +- **`RetentionSweeper`'s pending-episode guard protects nothing.** It reads + `getAllPendingEpisodeIds` and `getAllMidrollPendingEpisodeIds` so retention cannot reclaim a file + mid-detection. Nothing sets a pending basis any more, so both are always empty — harmless, but a + stale row on an existing phone would pin that episode's file for ever. **ID3 `CHAP` frames are dropped, not deferred** — decided 2026-09-26 by measuring Peter's actual 61 subscriptions rather than guessing. Every feed's newest episode was fetched far enough to read @@ -213,6 +208,14 @@ by default and correct; it is simply waiting for a publisher who marks breaks. - **Auto-downloads are not retried.** A rule's download that fails is left FAILED rather than requeued: retrying forever against a 404 would burn the battery, and the failed row is visible in the episode list. Retry is the manual Download button. +> **The three bullets below are history, about code that no longer exists.** They describe the +> on-device detector deleted on 2026-11-04 (see the Tier 3 entry above and the `on-device-detection` +> tag). Kept because the measurements in them were expensive and the same questions will come back +> if audio fingerprinting is ever wanted again — but nothing here names a file in this repo any +> more, so do not go looking for `DEFAULT_REFERENCE_SECONDS`, `MidrollUpdater.discover` or +> `IntroOutroDetectorScaleTest`. The surviving instruction from all of it is the one that is not +> about the app at all: tune detection against real audio off-device, never from the phone. + - **Tier 3 had never confirmed anything, and there were two independent reasons, both now fixed.** Not the correlation algorithm -- a real production-algorithm simulation against real Up First audio confirmed cleanly at the app's actual defaults before any of this. Do not tune diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 80c4dc7..d2769b3 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -392,12 +392,8 @@ val coverageExclusions = listOf( "**/data/AppGraph*", "**/ListenApp*", - // MediaExtractor/MediaCodec decode loop -- no JVM equivalent, same bar as PlayerConnection. - // AudioCorrelation, IntroOutroDetector, PcmCodec and FingerprintUpdater all stay measured: - // this is the one class between them that actually touches the Android media stack. - "**/fingerprint/MediaCodecClipSource*", - "**/work/FingerprintWorker*", - "**/work/MidrollWorker*", + // On-device detection used three entries here -- MediaCodecClipSource for the decode loop and + // the two workers that drove it. All three classes are gone; see the on-device-detection tag. ) tasks.register<JacocoReport>("jacocoTestReport") { diff --git a/app/src/main/java/org/terst/listen/data/Daos.kt b/app/src/main/java/org/terst/listen/data/Daos.kt index 12c605c..da31a88 100644 --- a/app/src/main/java/org/terst/listen/data/Daos.kt +++ b/app/src/main/java/org/terst/listen/data/Daos.kt @@ -440,11 +440,6 @@ interface MidrollDao { @Query("SELECT * FROM show_midrolls WHERE subscriptionId = :subscriptionId") suspend fun getCandidates(subscriptionId: String): List<ShowMidrollEntity> - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun upsertCandidate(candidate: ShowMidrollEntity) - - @Query("DELETE FROM show_midrolls WHERE subscriptionId = :subscriptionId AND id = :id") - suspend fun deleteCandidate(subscriptionId: String, id: String) // Ordered by start: MidrollSkip.skipTarget filters by kind before firstOrNull, so an // unordered result could let a short cue nested inside an ad pod win over the pod and @@ -466,22 +461,6 @@ interface MidrollDao { @Query("DELETE FROM episode_midrolls WHERE episodeId = :episodeId") suspend fun deleteRanges(episodeId: String) - /** Downloaded episodes of this show [MidrollDetector][org.terst.listen.fingerprint.MidrollDetector] - * has never looked at, oldest download first -- so a backlog of downloads is worked through - * in the order they actually landed, not newest-first forever leaving the rest unchecked. */ - @Query( - """SELECT * FROM episodes - WHERE subscriptionId = :subscriptionId AND downloadState = :downloaded - AND midrollCheckedAtMs IS NULL - ORDER BY downloadedAtMs ASC""" - ) - suspend fun getUncheckedDownloaded( - subscriptionId: String, - downloaded: DownloadState = DownloadState.DOWNLOADED, - ): List<EpisodeEntity> - - @Query("UPDATE episodes SET midrollCheckedAtMs = :ms WHERE id = :id") - suspend fun setMidrollCheckedAtMs(id: String, ms: Long) /** * Every episode id any show currently relies on as its midroll-discovery pending basis. diff --git a/app/src/main/java/org/terst/listen/fingerprint/AudioCorrelation.kt b/app/src/main/java/org/terst/listen/fingerprint/AudioCorrelation.kt deleted file mode 100644 index 1576bc8..0000000 --- a/app/src/main/java/org/terst/listen/fingerprint/AudioCorrelation.kt +++ /dev/null @@ -1,181 +0,0 @@ -package org.terst.listen.fingerprint - -import kotlin.math.max -import kotlin.math.sqrt - -/** - * Finds where a short reference clip best matches inside a longer recording. - * - * A Kotlin port of the desktop prototype's `normalized_xcorr`, validated against three of - * Peter's real shows before any of this existed. FFT-based, because the naive alternative -- - * a direct dot product at every offset -- is roughly (query length) * (target length) - * multiplications: for a 10-second reference clip against a 4-minute recording at 8kHz, that's - * on the order of 200 billion, far too slow for a phone. Convolution via FFT turns that into - * roughly (target length) * log(target length). - * - * The normalization matters as much as the FFT: correlating against each window's own local - * mean and variance, not the whole recording's. An earlier version of the desktop prototype - * got this wrong -- centered the whole target once, up front -- and it produced a plausible - * but bogus 0.98 "match" that a plain, unoptimized correlation at that exact position put at - * -0.02. [AudioCorrelationTest]'s self-test and ground-truth fixtures exist specifically to - * catch that mistake being ported here unnoticed. - */ -object AudioCorrelation { - - /** Where [query] best matches inside [target], and how strong that match is. */ - data class Match(val position: Int, val score: Double) - - /** A score below this isn't a real match -- see [bestMatch]'s silence/no-signal handling. */ - private const val MIN_QUERY_ENERGY = 1e-6 - - /** - * A target with its expensive, query-independent half already computed. - * - * The transform of the target and its rolling sums depend only on the target and on how long - * a query will be -- not on the query's contents. A window search correlates many queries - * against one unchanged target ([IntroOutroDetector]'s candidate search does it 117 times), - * and recomputing this for each of them was most of why fingerprinting could not finish - * inside a WorkManager worker. - * - * Opaque on purpose: [prepareTarget] is the only way to build one, so the invariant that the - * transform matches [querySize] cannot be broken from outside. - */ - class PreparedTarget internal constructor( - internal val targetSize: Int, - internal val querySize: Int, - internal val transformRe: DoubleArray, - internal val transformIm: DoubleArray, - internal val localSum: DoubleArray, - internal val localSumSq: DoubleArray, - ) - - /** - * [target] with everything that does not depend on the query's contents computed once, for - * queries of exactly [querySize] samples. - * - * Returns a prepared target even when [querySize] does not fit, so the caller does not have - * to duplicate that check; [bestMatch] reports the same null it always did. - */ - fun prepareTarget(target: DoubleArray, querySize: Int): PreparedTarget { - if (querySize <= 0 || querySize > target.size) { - return PreparedTarget(target.size, querySize, DoubleArray(0), DoubleArray(0), DoubleArray(0), DoubleArray(0)) - } - val n = Fft.nextPowerOfTwo(target.size + querySize - 1) - val re = target.copyOf(n) - val im = DoubleArray(n) - Fft.transform(re, im, inverse = false) - return PreparedTarget( - targetSize = target.size, - querySize = querySize, - transformRe = re, - transformIm = im, - localSum = rollingSum(target, querySize), - localSumSq = rollingSum(DoubleArray(target.size) { target[it] * target[it] }, querySize), - ) - } - - /** - * The best-aligned position of [query] inside [target], or null if there is nowhere valid - * to place it (the query is longer than the target) or the query itself carries no signal - * to match against (silence has an undefined correlation with anything). - */ - fun bestMatch(query: DoubleArray, target: DoubleArray): Match? = - bestMatch(query, prepareTarget(target, query.size)) - - /** - * The same answer as [bestMatch], against a target prepared once by [prepareTarget]. - * - * Null for a query whose length is not the one the target was prepared for: the prepared - * transform is sized for that length and silently reusing it at another would not be a - * slower correct answer, it would be a wrong one. - */ - fun bestMatch(query: DoubleArray, target: PreparedTarget): Match? { - if (query.size != target.querySize) return null - val scores = normalizedCrossCorrelation(query, target) ?: return null - if (scores.isEmpty()) return null - var bestIndex = 0 - var bestScore = scores[0] - for (i in 1 until scores.size) { - if (scores[i] > bestScore) { - bestScore = scores[i] - bestIndex = i - } - } - return Match(bestIndex, bestScore) - } - - /** - * A correlation-coefficient-like score at every valid alignment of [query] inside [target]: - * `scores[i]` is the Pearson correlation between `query` and `target[i until i + query.size]`. - * - * Derivation: let `q` be `query` mean-centered. For a window at offset `i`, - * `sum((target[i+k] - localMean) * (query[k] - queryMean))` -- the covariance numerator -- - * equals `sum(target[i+k] * q[k])` computed with `target` left *raw* (not mean-centered). - * That sum, as a function of `i`, is exactly the "valid" part of convolving `target` with - * the reverse of `q` -- which is what makes the FFT applicable at all. The denominator - * (each window's own standard deviation times the query's) comes from a rolling sum and - * sum-of-squares over `target`, needing no FFT. - */ - private fun normalizedCrossCorrelation(query: DoubleArray, target: PreparedTarget): DoubleArray? { - val win = query.size - if (win == 0 || win > target.targetSize) return null - - val queryMean = query.average() - val centeredQuery = DoubleArray(win) { query[it] - queryMean } - val queryNorm = sqrt(centeredQuery.sumOf { it * it }) - if (queryNorm < MIN_QUERY_ENERGY) return null - - val dot = validConvolution(target, reversed(centeredQuery)) - - val scores = DoubleArray(dot.size) - for (i in dot.indices) { - val mean = target.localSum[i] / win - val variance = max(target.localSumSq[i] / win - mean * mean, 0.0) - val localNorm = sqrt(variance * win) - val denom = localNorm * queryNorm - scores[i] = if (denom < MIN_QUERY_ENERGY) 0.0 else dot[i] / denom - } - return scores - } - - private fun reversed(a: DoubleArray): DoubleArray { - val out = DoubleArray(a.size) - for (i in a.indices) out[i] = a[a.size - 1 - i] - return out - } - - /** Sum of each length-[win] window of [a], one per valid starting offset. */ - private fun rollingSum(a: DoubleArray, win: Int): DoubleArray { - val prefix = DoubleArray(a.size + 1) - for (i in a.indices) prefix[i + 1] = prefix[i] + a[i] - val out = DoubleArray(a.size - win + 1) - for (i in out.indices) out[i] = prefix[i + win] - prefix[i] - return out - } - - /** - * The "valid" slice of convolving [target] with [kernel]: length `target.targetSize - - * kernel.size + 1`, computed via zero-padded FFT rather than the O(n*m) direct sum. - * - * Two transforms, not three: the target's was already done by [prepareTarget]. - */ - private fun validConvolution(target: PreparedTarget, kernel: DoubleArray): DoubleArray { - val n = target.transformRe.size - - val kernelRe = kernel.copyOf(n) - val kernelIm = DoubleArray(n) - Fft.transform(kernelRe, kernelIm, inverse = false) - - val productRe = DoubleArray(n) - val productIm = DoubleArray(n) - for (i in 0 until n) { - productRe[i] = target.transformRe[i] * kernelRe[i] - target.transformIm[i] * kernelIm[i] - productIm[i] = target.transformRe[i] * kernelIm[i] + target.transformIm[i] * kernelRe[i] - } - Fft.transform(productRe, productIm, inverse = true) - - // The valid region starts once the kernel is fully overlapped with the target. - val validLength = target.targetSize - kernel.size + 1 - return DoubleArray(validLength) { productRe[it + kernel.size - 1] } - } -} diff --git a/app/src/main/java/org/terst/listen/fingerprint/Decimate.kt b/app/src/main/java/org/terst/listen/fingerprint/Decimate.kt deleted file mode 100644 index 9b7acd4..0000000 --- a/app/src/main/java/org/terst/listen/fingerprint/Decimate.kt +++ /dev/null @@ -1,24 +0,0 @@ -package org.terst.listen.fingerprint - -/** - * Downsamples by averaging fixed-size blocks, rather than dropping samples. - * - * Only used as a cheap pre-filter ahead of [MidrollScanner]'s coarse pass: averaging keeps the - * signal's overall shape (which is all a coarse alignment search needs) where naive decimation - * (keeping every Nth sample) would alias, and correctness of the *real* match still comes from - * [AudioCorrelation] re-checking at full resolution afterwards. - */ -object Decimate { - - fun byAveraging(samples: DoubleArray, factor: Int): DoubleArray { - require(factor >= 1) { "factor must be at least 1" } - if (factor == 1) return samples - val outLength = samples.size / factor - return DoubleArray(outLength) { i -> - var sum = 0.0 - val base = i * factor - for (j in 0 until factor) sum += samples[base + j] - sum / factor - } - } -} diff --git a/app/src/main/java/org/terst/listen/fingerprint/Fft.kt b/app/src/main/java/org/terst/listen/fingerprint/Fft.kt deleted file mode 100644 index b2feff8..0000000 --- a/app/src/main/java/org/terst/listen/fingerprint/Fft.kt +++ /dev/null @@ -1,83 +0,0 @@ -package org.terst.listen.fingerprint - -import kotlin.math.PI -import kotlin.math.cos -import kotlin.math.sin - -/** - * A minimal iterative radix-2 Cooley-Tukey FFT. [AudioCorrelation] is the only reason this app - * needs a Fourier transform at all. - */ -internal object Fft { - - fun nextPowerOfTwo(n: Int): Int { - var p = 1 - while (p < n) p = p shl 1 - return p - } - - /** - * In-place FFT (or inverse FFT) of a complex signal given as parallel real/imaginary - * arrays. `re.size` must be a power of two. - */ - fun transform(re: DoubleArray, im: DoubleArray, inverse: Boolean) { - val n = re.size - require(n and (n - 1) == 0) { "FFT length must be a power of two, was $n" } - if (n <= 1) return - - bitReverse(re, im) - - var size = 2 - while (size <= n) { - val halfSize = size / 2 - val angleStep = (if (inverse) 1.0 else -1.0) * 2.0 * PI / size - var start = 0 - while (start < n) { - for (k in 0 until halfSize) { - val angle = angleStep * k - val wRe = cos(angle) - val wIm = sin(angle) - val evenIndex = start + k - val oddIndex = start + k + halfSize - - val oddRe = re[oddIndex] * wRe - im[oddIndex] * wIm - val oddIm = re[oddIndex] * wIm + im[oddIndex] * wRe - - val evenRe = re[evenIndex] - val evenIm = im[evenIndex] - - re[evenIndex] = evenRe + oddRe - im[evenIndex] = evenIm + oddIm - re[oddIndex] = evenRe - oddRe - im[oddIndex] = evenIm - oddIm - } - start += size - } - size *= 2 - } - - if (inverse) { - for (i in 0 until n) { - re[i] /= n - im[i] /= n - } - } - } - - private fun bitReverse(re: DoubleArray, im: DoubleArray) { - val n = re.size - var j = 0 - for (i in 0 until n) { - if (i < j) { - val tr = re[i]; re[i] = re[j]; re[j] = tr - val ti = im[i]; im[i] = im[j]; im[j] = ti - } - var bit = n shr 1 - while (bit != 0 && j and bit != 0) { - j = j xor bit - bit = bit shr 1 - } - j = j xor bit - } - } -} diff --git a/app/src/main/java/org/terst/listen/fingerprint/FingerprintUpdater.kt b/app/src/main/java/org/terst/listen/fingerprint/FingerprintUpdater.kt deleted file mode 100644 index bf73e9d..0000000 --- a/app/src/main/java/org/terst/listen/fingerprint/FingerprintUpdater.kt +++ /dev/null @@ -1,158 +0,0 @@ -package org.terst.listen.fingerprint - -import org.terst.listen.data.EpisodeDao -import org.terst.listen.data.EpisodeEntity -import org.terst.listen.data.FingerprintKind -import org.terst.listen.data.ShowFingerprintDao -import org.terst.listen.data.ShowFingerprintEntity -import org.terst.listen.data.playbackDurationMs - -/** A short, decoded window of an episode's audio, and where it sits in the episode's own timeline. */ -data class DecodedClip(val samples: DoubleArray, val sampleRate: Int, val startMs: Long) - -/** - * Decodes the head or tail window of a downloaded episode that fingerprint matching needs. - * - * Isolated behind an interface for the same reason [org.terst.listen.download.ExtractedAudioSource] - * is: nothing implementing this can run without a real Android decoder, so the logic that - * decides what to do with a decoded clip stays testable off device. - */ -interface AudioClipSource { - /** Null if the episode has nothing playable yet, or decoding otherwise fails. */ - suspend fun decodeHead(episode: EpisodeEntity): DecodedClip? - suspend fun decodeTail(episode: EpisodeEntity): DecodedClip? - - /** - * The episode's *entire* decoded audio, for [MidrollUpdater] -- a midroll ad break can land - * anywhere in the body, so unlike [decodeHead]/[decodeTail] there is no fixed short window to - * ask for. Null under the same conditions, plus whenever the episode's length is not known - * up front (needed to size the decode). - */ - suspend fun decodeFull(episode: EpisodeEntity): DecodedClip? -} - -/** - * Runs [IntroOutroDetector] for one newly downloaded episode against both ends of its show's - * tracked fingerprint state, and persists whatever it decides. - * - * Only the episode just processed ever gets a detected value written -- there is deliberately - * no backfill onto a show's older episodes once a fingerprint is newly confirmed. Those may - * already have been reclaimed by retention, and Tier 3's actual value is in episodes still to - * come; the manual Tier 2 offset remains the fallback for anything already played. - */ -class FingerprintUpdater( - private val fingerprints: ShowFingerprintDao, - private val episodes: EpisodeDao, - private val clips: AudioClipSource, - private val referenceLengthSamples: Int = DEFAULT_REFERENCE_SECONDS * DEFAULT_SAMPLE_RATE, - private val now: () -> Long = System::currentTimeMillis, -) { - - suspend fun process(episode: EpisodeEntity) { - if (episode.localPath == null) return - clips.decodeHead(episode)?.let { processEnd(episode, FingerprintKind.INTRO, it) } - clips.decodeTail(episode)?.let { processEnd(episode, FingerprintKind.OUTRO, it) } - } - - private suspend fun processEnd(episode: EpisodeEntity, kind: FingerprintKind, clip: DecodedClip) { - val stored = fingerprints.get(episode.subscriptionId, kind) - val state = stateOf(stored, kind) - val update = IntroOutroDetector.check(state, episode.id, clip.samples, referenceLengthSamples) - - fingerprints.upsert(entityOf(update.state, episode.subscriptionId, kind, clip.sampleRate)) - - if (update is FingerprintUpdate.Confirmed) { - applyDetected(episode, kind, clip, update) - } - } - - private suspend fun applyDetected( - episode: EpisodeEntity, - kind: FingerprintKind, - clip: DecodedClip, - confirmed: FingerprintUpdate.Confirmed, - ) { - val matchedStartMs = clip.startMs + confirmed.positionInClip.toLong() * MS_PER_SECOND / clip.sampleRate - when (kind) { - FingerprintKind.INTRO -> { - val matchedEndMs = matchedStartMs + - confirmed.lengthSamples.toLong() * MS_PER_SECOND / clip.sampleRate - fingerprints.setDetectedIntroMs(episode.id, matchedEndMs) - } - - FingerprintKind.OUTRO -> { - val durationMs = episode.playbackDurationMs ?: return - fingerprints.setDetectedOutroMs(episode.id, (durationMs - matchedStartMs).coerceAtLeast(0)) - } - } - } - - private suspend fun stateOf(stored: ShowFingerprintEntity?, kind: FingerprintKind): FingerprintState { - if (stored == null) return FingerprintState() - return FingerprintState( - referenceClip = stored.referenceClipPcm?.let(PcmCodec::decode), - pendingClip = pendingClipOf(stored, kind), - pendingEpisodeId = stored.pendingEpisodeId, - confirmedCount = stored.confirmedCount, - lastConfirmedEpisodeId = stored.lastConfirmedEpisodeId, - ) - } - - /** - * Re-decodes the pending episode rather than reading a clip back out of the database. - * - * The clip is a whole head or tail window and does not fit in a row -- see - * [org.terst.listen.data.ShowFingerprintEntity.pendingEpisodeId]. Decoding again costs one - * pass over a file already on disk, and only until a first candidate exists. - * - * Null whenever that episode or its file has gone: the detector then treats the episode in - * hand as the new pending one, which is the same place it starts from anyway. - */ - private suspend fun pendingClipOf(stored: ShowFingerprintEntity, kind: FingerprintKind): DoubleArray? { - if (stored.referenceClipPcm != null) return null - val pendingId = stored.pendingEpisodeId ?: return null - val pending = episodes.getById(pendingId) ?: return null - if (pending.localPath == null) return null - - val clip = when (kind) { - FingerprintKind.INTRO -> clips.decodeHead(pending) - FingerprintKind.OUTRO -> clips.decodeTail(pending) - } - return clip?.samples - } - - private fun entityOf( - state: FingerprintState, - subscriptionId: String, - kind: FingerprintKind, - sampleRate: Int, - ) = ShowFingerprintEntity( - subscriptionId = subscriptionId, - kind = kind, - referenceClipPcm = state.referenceClip?.let(::storablePcm), - pendingEpisodeId = state.pendingEpisodeId, - sampleRate = sampleRate, - confirmedCount = state.confirmedCount, - lastConfirmedEpisodeId = state.lastConfirmedEpisodeId, - updatedAtMs = now(), - ) - - /** - * The reference clip as bytes, or null if it could not be read back out again. - * - * Unreachable while [DEFAULT_REFERENCE_SECONDS] stays at eight -- that is 128KB against a - * 1MB budget -- and here precisely because this table once held a clip that *did* exceed - * it. The row inserted cleanly and threw `SQLiteBlobTooBigException` on the next read, - * taking the app down. Dropping the candidate restarts detection for that show, which - * costs time; writing the row costs a crash. - */ - private fun storablePcm(clip: DoubleArray): ByteArray? = - PcmCodec.encode(clip).takeIf { StoredClip.fits(it.size) } - - companion object { - /** Public so [StoredClip]'s guard can measure them. */ - const val DEFAULT_REFERENCE_SECONDS = 8 - const val DEFAULT_SAMPLE_RATE = 8_000 - private const val MS_PER_SECOND = 1_000L - } -} diff --git a/app/src/main/java/org/terst/listen/fingerprint/IntroOutroDetector.kt b/app/src/main/java/org/terst/listen/fingerprint/IntroOutroDetector.kt deleted file mode 100644 index ebbe4a6..0000000 --- a/app/src/main/java/org/terst/listen/fingerprint/IntroOutroDetector.kt +++ /dev/null @@ -1,285 +0,0 @@ -package org.terst.listen.fingerprint - -/** - * Tracked state of a show's fingerprint for one end (intro or outro) of its episodes. - * - * [referenceClip] is null until a first candidate segment has been found by comparing two - * episodes; [pendingClip] holds the most recently processed episode's own clip only while there - * is no reference yet, so the next episode has something to compare against. - */ -data class FingerprintState( - val referenceClip: DoubleArray? = null, - val pendingClip: DoubleArray? = null, - val pendingEpisodeId: String? = null, - val confirmedCount: Int = 0, - val lastConfirmedEpisodeId: String? = null, -) - -/** What checking one more episode against the tracked state decided. */ -sealed interface FingerprintUpdate { - val state: FingerprintState - - /** Nothing usable yet -- keep waiting for another episode. */ - data class Waiting(override val state: FingerprintState) : FingerprintUpdate - - /** A candidate exists but hasn't been independently confirmed enough times to trust yet. */ - data class Tentative(override val state: FingerprintState) : FingerprintUpdate - - /** - * Confirmed: safe to apply. [positionInClip] and [lengthSamples] locate the matched - * segment within the clip that was just checked, in samples -- the caller converts that to - * milliseconds using whatever offset that clip started at within the real episode. - */ - data class Confirmed( - override val state: FingerprintState, - val positionInClip: Int, - val lengthSamples: Int, - ) : FingerprintUpdate -} - -/** - * Decides whether a show's recurring intro or outro can be trusted yet, one newly-processed - * episode at a time. - * - * Pure and storage-free, like [org.terst.listen.queue.DripSchedule] -- callers own persisting - * [FingerprintState] and converting a [FingerprintUpdate.Confirmed]'s clip-relative position - * into real milliseconds. - * - * The confirmation threshold exists because of a real trap found prototyping this against - * Peter's actual shows: two episodes fetched close together in time can agree on a segment that - * is a dynamically-inserted ad, not the show's real intro -- confirmed by checking a years-old - * archived episode, which shared the same "matching" segment despite the gap, while two other - * recent episodes (fetched at the same time as the false match) shared nothing. A third, - * genuinely independent episode is very unlikely to also get assigned the same rotating ad, so - * requiring one is the actual safety mechanism here, not just a number picked for caution. - */ -object IntroOutroDetector { - - /** Public so the UI can say "2 of 3" rather than hardcoding the same number again. */ - const val CONFIRM_THRESHOLD = 3 - private const val MIN_SCORE = 0.6 - private const val CANDIDATE_STEP_DIVISOR = 4 - - /** Matches [MidrollUpdater]'s own coarse factor, which was validated against real shows. */ - private const val DEFAULT_COARSE_FACTOR = 8 - private const val DEFAULT_FINE_CANDIDATES = 3 - - /** - * Below this many samples a coarse window stops carrying enough signal to rank positions by, - * so the search runs at full resolution instead. Also what keeps the unit tests' 300-sample - * clips on the path they were written against. - */ - private const val MIN_COARSE_WINDOW_SAMPLES = 512 - - /** - * What the candidate search is allowed to cost. Mirrors [MidrollUpdater.Tuning], including - * the reason it exists: the search's resolution is a performance choice, and the only honest - * way to show that is to make it a value a test can set. - * - * Nothing here is a detection threshold. `MIN_SCORE`, `CONFIRM_THRESHOLD` and the reference - * length are deliberately *not* reachable from the app or from tuning -- see CLAUDE.md. - */ - data class Tuning( - /** - * How much to shrink both clips before searching for a candidate position. 1 searches at - * full resolution, which is what this did before and is kept reachable so the fast path - * can be compared against it. - */ - val coarseFactor: Int = DEFAULT_COARSE_FACTOR, - /** - * How many coarse hits get verified at full resolution. More than one because - * decimation can reorder near-equal peaks, and the real intro losing a coarse tie to a - * spurious one would otherwise be unrecoverable. - */ - val fineCandidates: Int = DEFAULT_FINE_CANDIDATES, - /** - * How many independently-fetched episodes must agree before a candidate is trusted. - * - * Here with the cost knobs rather than as its own argument because [MidrollUpdater.Tuning] - * already carries the same value the same way -- but unlike them it is a *safety* - * threshold, and the reason it is three is in this class's own documentation. It is - * reachable for tests, not for the app; see CLAUDE.md. - */ - val confirmThreshold: Int = CONFIRM_THRESHOLD, - ) - - fun check( - state: FingerprintState, - episodeId: String, - clip: DoubleArray, - referenceLengthSamples: Int, - tuning: Tuning = Tuning(), - ): FingerprintUpdate { - if (episodeId == state.lastConfirmedEpisodeId || episodeId == state.pendingEpisodeId) { - return toUpdate(state, tuning.confirmThreshold) - } - - val reference = state.referenceClip - if (reference == null) { - return checkAgainstPending(state, episodeId, clip, referenceLengthSamples, tuning) - } - return checkAgainstReference(state, episodeId, clip, reference, tuning.confirmThreshold) - } - - private fun checkAgainstPending( - state: FingerprintState, - episodeId: String, - clip: DoubleArray, - referenceLengthSamples: Int, - tuning: Tuning, - ): FingerprintUpdate { - val pending = state.pendingClip - if (pending == null) { - return FingerprintUpdate.Waiting( - FingerprintState(pendingClip = clip, pendingEpisodeId = episodeId) - ) - } - - val candidate = findCandidate(pending, clip, referenceLengthSamples, tuning) - if (candidate == null) { - // No shared segment between the only two episodes seen so far -- this one becomes - // the new basis, same as if nothing had ever been seen. - return FingerprintUpdate.Waiting( - FingerprintState(pendingClip = clip, pendingEpisodeId = episodeId) - ) - } - - return FingerprintUpdate.Tentative( - state.copy( - referenceClip = candidate, - pendingClip = null, - pendingEpisodeId = null, - confirmedCount = 2, - lastConfirmedEpisodeId = episodeId, - ) - ) - } - - private fun checkAgainstReference( - state: FingerprintState, - episodeId: String, - clip: DoubleArray, - reference: DoubleArray, - confirmThreshold: Int, - ): FingerprintUpdate { - val match = AudioCorrelation.bestMatch(reference, clip) - if (match == null || match.score < MIN_SCORE) { - // The Darknet Diaries case: an existing candidate did not hold up. Discarded - // outright, not weakened -- this episode becomes the fresh basis instead. - return FingerprintUpdate.Waiting( - FingerprintState(pendingClip = clip, pendingEpisodeId = episodeId) - ) - } - - val newState = state.copy( - confirmedCount = state.confirmedCount + 1, - lastConfirmedEpisodeId = episodeId, - ) - return if (newState.confirmedCount >= confirmThreshold) { - FingerprintUpdate.Confirmed(newState, match.position, reference.size) - } else { - FingerprintUpdate.Tentative(newState) - } - } - - /** - * Finds a [referenceLengthSamples]-long segment of [a] that also occurs in [b], or null if - * nothing matches strongly enough to trust. - * - * Coarse-to-fine, the composition [MidrollUpdater] already uses and for the same reason: the - * *position* of a shared segment can be found on heavily decimated audio for a fraction of - * the cost, while whether it is really a match is still decided at full resolution. Searched - * flat at full resolution this took 199 seconds per end on a desktop -- measured, in - * [IntroOutroDetectorScaleTest] -- which is how fingerprinting came to burn a WorkManager - * worker's entire ten-minute allowance and persist nothing. - * - * The step within the search is a quarter-window, not a whole one. A step equal to the full - * window would almost always straddle a real embedded segment rather than land on it (any - * embed position not a multiple of the window length gets split across two candidate - * windows, each only partially the real signal, which is often too weak to clear - * [MIN_SCORE]). - */ - private fun findCandidate( - a: DoubleArray, - b: DoubleArray, - referenceLengthSamples: Int, - tuning: Tuning, - ): DoubleArray? { - val win = referenceLengthSamples.coerceAtMost(a.size).coerceAtMost(b.size) - if (win <= 0) return null - - val factor = coarseFactorFor(win, tuning.coarseFactor) - val positions = if (factor == 1) { - searchPositions(a, b, win, tuning.fineCandidates) - } else { - searchPositions(Decimate.byAveraging(a, factor), Decimate.byAveraging(b, factor), win / factor, tuning.fineCandidates) - .map { it * factor } - } - - return bestVerifiedWindow(a, b, win, positions) - } - - /** - * Decimation is skipped rather than allowed to shrink the search window into noise: a - * window of a handful of samples correlates with anything, and the clips the unit tests use - * are three hundred samples long in total. - */ - private fun coarseFactorFor(win: Int, requested: Int): Int { - val factor = requested.coerceAtLeast(1) - if (factor == 1) return 1 - return if (win / factor >= MIN_COARSE_WINDOW_SAMPLES) factor else 1 - } - - /** - * The best-scoring window start positions in [a], highest first -- in whatever resolution - * [a] and [b] are handed in at. - * - * No score threshold here on purpose. This pass only ranks positions; at a coarse resolution - * its absolute scores are not the ones detection is allowed to decide on, so rejecting here - * would be rejecting on a number the fine pass is about to compute properly. - */ - private fun searchPositions(a: DoubleArray, b: DoubleArray, win: Int, keep: Int): List<Int> { - if (win <= 0 || win > a.size || win > b.size) return emptyList() - val step = (win / CANDIDATE_STEP_DIVISOR).coerceAtLeast(1) - val target = AudioCorrelation.prepareTarget(b, win) - - val scored = mutableListOf<Pair<Int, Double>>() - var start = 0 - while (start + win <= a.size) { - AudioCorrelation.bestMatch(a.copyOfRange(start, start + win), target) - ?.let { scored += start to it.score } - start += step - } - return scored.sortedByDescending { it.second }.take(keep.coerceAtLeast(1)).map { it.first } - } - - /** - * The first candidate position whose full-resolution score clears [MIN_SCORE], as a window of - * [a] -- which is the clip the reference is kept from, exactly as before. - * - * This is where detection actually happens: every number the coarse pass produced is - * discarded and the decision is made on native-resolution audio. - */ - private fun bestVerifiedWindow(a: DoubleArray, b: DoubleArray, win: Int, positions: List<Int>): DoubleArray? { - val target = AudioCorrelation.prepareTarget(b, win) - var bestScore = MIN_SCORE - var bestWindow: DoubleArray? = null - - for (position in positions) { - val start = position.coerceIn(0, a.size - win) - val window = a.copyOfRange(start, start + win) - val match = AudioCorrelation.bestMatch(window, target) - if (match != null && match.score > bestScore) { - bestScore = match.score - bestWindow = window - } - } - return bestWindow - } - - private fun toUpdate(state: FingerprintState, confirmThreshold: Int): FingerprintUpdate = when { - state.confirmedCount >= confirmThreshold -> FingerprintUpdate.Confirmed(state, 0, 0) - state.referenceClip != null -> FingerprintUpdate.Tentative(state) - else -> FingerprintUpdate.Waiting(state) - } -} diff --git a/app/src/main/java/org/terst/listen/fingerprint/MediaCodecClipSource.kt b/app/src/main/java/org/terst/listen/fingerprint/MediaCodecClipSource.kt deleted file mode 100644 index 7294ba9..0000000 --- a/app/src/main/java/org/terst/listen/fingerprint/MediaCodecClipSource.kt +++ /dev/null @@ -1,207 +0,0 @@ -package org.terst.listen.fingerprint - -import android.media.MediaCodec -import android.media.MediaExtractor -import android.media.MediaFormat -import java.nio.ByteOrder -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import org.terst.listen.data.EpisodeEntity -import org.terst.listen.data.playbackDurationMs - -/** - * The real clip decoder, backed by Android's `MediaExtractor`/`MediaCodec`. - * - * Isolated behind [AudioClipSource] so [FingerprintUpdater]'s own logic stays testable off - * device, the same reason [org.terst.listen.download.ChaquopyAudioSource] is isolated behind - * an interface -- nothing here can run without a real Android media stack, and that is exactly - * why it is on the framework-adapter exclusion list in `app/build.gradle.kts` rather than - * counted against the coverage gate. - * - * Decodes to mono at a fixed low rate (8kHz): plenty of signal for a spoken or musical - * sting, and keeping every clip at the same rate means [IntroOutroDetector] never has to care - * what an episode's own encoding was. - */ -class MediaCodecClipSource : AudioClipSource { - - override suspend fun decodeHead(episode: EpisodeEntity): DecodedClip? { - val path = episode.localPath ?: return null - return decode(path, startMs = 0, CLIP_SECONDS) - } - - override suspend fun decodeTail(episode: EpisodeEntity): DecodedClip? { - val path = episode.localPath ?: return null - val totalMs = episode.playbackDurationMs ?: return null - val startMs = (totalMs - CLIP_SECONDS * MS_PER_SECOND).coerceAtLeast(0) - return decode(path, startMs, CLIP_SECONDS) - } - - /** - * Unlike [decodeHead]/[decodeTail], there is no fixed window: a midroll ad can be anywhere, - * so the whole episode is decoded. [EpisodeEntity.durationMs] bounds how far to read rather - * than sizing an allocation -- see [MonoResampler] for why that distinction is the whole - * reason this path used to fail. - * - * How long an episode this is *worth* attempting is [MidrollUpdater]'s call, not this - * class's: the result is still held whole in memory by whoever asked for it. - */ - override suspend fun decodeFull(episode: EpisodeEntity): DecodedClip? { - val path = episode.localPath ?: return null - val totalMs = episode.playbackDurationMs ?: return null - val clipSeconds = (totalMs / MS_PER_SECOND + FULL_DECODE_MARGIN_SECONDS).toInt() - return decode(path, startMs = 0, clipSeconds) - } - - private suspend fun decode(path: String, startMs: Long, clipSeconds: Int): DecodedClip? = - withContext(Dispatchers.Default) { - runCatching { decodeBlocking(path, startMs, clipSeconds) }.getOrNull() - } - - private fun decodeBlocking(path: String, startMs: Long, clipSeconds: Int): DecodedClip { - val extractor = MediaExtractor() - try { - extractor.setDataSource(path) - val trackIndex = audioTrackIndex(extractor) - extractor.selectTrack(trackIndex) - val format = extractor.getTrackFormat(trackIndex) - val inputRate = format.getInteger(MediaFormat.KEY_SAMPLE_RATE) - val channels = format.getInteger(MediaFormat.KEY_CHANNEL_COUNT) - extractor.seekTo(startMs * MICROS_PER_MS, MediaExtractor.SEEK_TO_CLOSEST_SYNC) - - val resampler = MonoResampler(inputRate, channels, TARGET_SAMPLE_RATE) - decodeInto(extractor, format, resampler, channels, clipSeconds.toLong() * inputRate * channels) - return DecodedClip(resampler.finish(), TARGET_SAMPLE_RATE, startMs) - } finally { - extractor.release() - } - } - - private fun audioTrackIndex(extractor: MediaExtractor): Int { - for (i in 0 until extractor.trackCount) { - val mime = extractor.getTrackFormat(i).getString(MediaFormat.KEY_MIME) - if (mime?.startsWith("audio/") == true) return i - } - error("No audio track in this file") - } - - /** - * Pumps the decoder into [resampler] until [maxSamples] interleaved samples have been read - * or the track ends. - * - * Nothing here holds the decoded audio: each output buffer is copied into a small reused - * scratch array and handed straight on, so the bound is a stop condition rather than an - * allocation. That is the whole difference from the version this replaces, which asked for - * 145MB of native-rate PCM to produce 6MB of 8kHz mono and died doing it. - */ - private fun decodeInto( - extractor: MediaExtractor, - format: MediaFormat, - resampler: MonoResampler, - channels: Int, - maxSamples: Long, - ) { - val mime = format.getString(MediaFormat.KEY_MIME) ?: error("No MIME on the audio track") - val codec = MediaCodec.createDecoderByType(mime) - try { - codec.configure(format, null, null, 0) - codec.start() - - val pump = OutputPump(codec, resampler, channels, maxSamples) - var inputDone = false - while (!pump.finished) { - if (!inputDone) { - inputDone = feedInput(extractor, codec) - } - pump.drain() - } - } finally { - codec.stop() - codec.release() - } - } - - /** Returns true once the extractor has nothing left to feed. */ - private fun feedInput(extractor: MediaExtractor, codec: MediaCodec): Boolean { - val inputIndex = codec.dequeueInputBuffer(TIMEOUT_US) - if (inputIndex < 0) return false - val buffer = codec.getInputBuffer(inputIndex) ?: return false - val sampleSize = extractor.readSampleData(buffer, 0) - return if (sampleSize < 0) { - codec.queueInputBuffer(inputIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM) - true - } else { - codec.queueInputBuffer(inputIndex, 0, sampleSize, extractor.sampleTime, 0) - extractor.advance() - false - } - } - - /** - * One decoder's output side, with the running count it needs. - * - * A class rather than a function taking all of it: the loop needs a sample count, an - * end-of-stream flag and a reusable buffer carried between calls, and threading those - * through arguments produced a seven-parameter function that said less than this does. - */ - private class OutputPump( - private val codec: MediaCodec, - private val resampler: MonoResampler, - channels: Int, - private val maxSamples: Long, - ) { - private val info = MediaCodec.BufferInfo() - - // A multiple of the channel count, so copying never splits a frame unless the decoder's - // own buffer already did -- which MonoResampler carries across anyway. - private val scratch = ShortArray(SCRATCH_FRAMES * channels) - - private var samplesRead = 0L - private var endOfStream = false - - /** Whether there is no more to read: the track ended, or enough has been taken. */ - val finished: Boolean get() = endOfStream || samplesRead >= maxSamples - - /** Hands one output buffer to the resampler, if one is ready. */ - fun drain() { - val outputIndex = codec.dequeueOutputBuffer(info, TIMEOUT_US) - if (outputIndex < 0) return - - if (info.size > 0) { - codec.getOutputBuffer(outputIndex)?.let { copyOut(it) } - } - codec.releaseOutputBuffer(outputIndex, false) - if (info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) endOfStream = true - } - - private fun copyOut(output: java.nio.ByteBuffer) { - output.position(info.offset) - output.limit(info.offset + info.size) - val shorts = output.order(ByteOrder.LITTLE_ENDIAN).asShortBuffer() - while (shorts.hasRemaining() && samplesRead < maxSamples) { - val room = minOf(maxSamples - samplesRead, scratch.size.toLong()).toInt() - val count = minOf(shorts.remaining(), room) - shorts.get(scratch, 0, count) - resampler.accept(scratch, 0, count) - samplesRead += count - } - } - } - - companion object { - /** - * The head/tail window decoded for matching. Public because [StoredClip]'s size guard - * measures it: this window is far too big to persist, and a test says so rather than a - * comment nobody reads. - */ - const val CLIP_SECONDS = 240 - // durationMs is the feed's own claim, occasionally a little short of the real file. - const val FULL_DECODE_MARGIN_SECONDS = 30 - const val TARGET_SAMPLE_RATE = 8_000 - const val TIMEOUT_US = 10_000L - - /** Frames copied out of one decoder buffer at a time; see [decodeInto]. */ - const val SCRATCH_FRAMES = 4_096 - const val MICROS_PER_MS = 1_000L - const val MS_PER_SECOND = 1_000L - } -} diff --git a/app/src/main/java/org/terst/listen/fingerprint/MidrollClipStore.kt b/app/src/main/java/org/terst/listen/fingerprint/MidrollClipStore.kt index ca4cc23..7863929 100644 --- a/app/src/main/java/org/terst/listen/fingerprint/MidrollClipStore.kt +++ b/app/src/main/java/org/terst/listen/fingerprint/MidrollClipStore.kt @@ -3,60 +3,32 @@ package org.terst.listen.fingerprint import java.io.File /** - * Where a midroll candidate's reference audio is kept. + * Deletes the reference audio on-device midroll detection used to keep. * - * Not in the database. A real ad break runs for minutes -- Tangle and The Daily Show wrap - * theirs in theme music, so the segment worth matching includes the music at both ends -- and - * at 8kHz 16-bit PCM three minutes is 2.88MB, past what a CursorWindow will hand back. Capping - * the clip to fit a row was tried and is wrong: it truncates the very thing being identified. + * All that is left of a store that also wrote and read clips. Detection moved to the server and + * the classes that drove this went with it, but the files they wrote are still sitting on any + * phone that ran that version -- three minutes of 8kHz PCM per candidate, per show -- and + * unsubscribing is the one moment anything knows they can go. * - * Behind an interface so [MidrollUpdater] stays testable without a device, though the file - * implementation has no Android in it either and is measured like any other class. + * So this is cleanup for a feature that no longer exists, deliberately kept. Deleting it too + * would not break anything; it would quietly leak every clip on Peter's phone instead. */ interface MidrollClipStore { - /** The stored path, or null if writing failed. */ - suspend fun write(subscriptionId: String, candidateId: String, samples: DoubleArray): String? - - /** Null when the file has gone, which the caller treats as the candidate having gone. */ - suspend fun read(path: String): DoubleArray? - - suspend fun delete(path: String) - /** Every clip belonging to one show, for when it is unsubscribed. */ suspend fun deleteAllFor(subscriptionId: String) } class FileMidrollClipStore(private val root: File) : MidrollClipStore { - override suspend fun write( - subscriptionId: String, - candidateId: String, - samples: DoubleArray, - ): String? = runCatching { - val dir = File(root, safe(subscriptionId)).apply { mkdirs() } - val file = File(dir, "${safe(candidateId)}.pcm") - file.writeBytes(PcmCodec.encode(samples)) - file.absolutePath - }.getOrNull() - - override suspend fun read(path: String): DoubleArray? = runCatching { - val file = File(path) - if (!file.isFile) return null - PcmCodec.decode(file.readBytes()) - }.getOrNull() - - override suspend fun delete(path: String) { - runCatching { File(path).delete() } - } - override suspend fun deleteAllFor(subscriptionId: String) { runCatching { File(root, safe(subscriptionId)).deleteRecursively() } } /** - * Ids reach here from feeds, and a separator in one would otherwise write outside the + * Ids reach here from feeds, and a separator in one would otherwise reach outside the * store -- the same sanitising [org.terst.listen.download.EpisodeDownloader] does for - * episode files, for the same reason. + * episode files, for the same reason. It has to match what the old writer used, or the + * directory it made would not be the one this looks for. */ private fun safe(id: String) = id .map { if (it.isLetterOrDigit() || it == '-' || it == '_') it else '_' } diff --git a/app/src/main/java/org/terst/listen/fingerprint/MidrollDetector.kt b/app/src/main/java/org/terst/listen/fingerprint/MidrollDetector.kt deleted file mode 100644 index 8cbb441..0000000 --- a/app/src/main/java/org/terst/listen/fingerprint/MidrollDetector.kt +++ /dev/null @@ -1,91 +0,0 @@ -package org.terst.listen.fingerprint - -/** - * Decides whether each of a show's tracked midroll candidates still holds up against one more - * episode, and reports which ones are trusted enough to act on. - * - * The confirm/discard shape is exactly [IntroOutroDetector]'s -- the same Darknet Diaries trap - * applies here too, a segment two episodes happen to share by coincidence must not survive a - * genuinely independent episode that lacks it. The difference is scale, not kind: a show can have - * several distinct midroll breaks at once, each tracked as its own [Candidate] rather than one - * fixed intro/outro slot, and *finding* a new one means scanning a whole episode body (see - * [MidrollScanner]) rather than comparing two short fixed-position clips -- which is a separate, - * heavier concern this object deliberately knows nothing about. [MidrollUpdater] owns discovery; - * this only ever judges candidates that already exist. - */ -object MidrollDetector { - - /** Public for the same reason as [IntroOutroDetector.CONFIRM_THRESHOLD]. */ - const val CONFIRM_THRESHOLD = 3 - private const val MIN_SCORE = 0.6 - - data class Candidate( - val id: String, - val referenceClip: DoubleArray, - val confirmedCount: Int, - val lastConfirmedEpisodeId: String? = null, - ) - - /** Where a candidate matched in the episode just checked, in samples. */ - data class Detected(val candidateId: String, val positionInClip: Int, val lengthSamples: Int) - - /** - * [detected] is only the candidates that crossed [confirmThreshold] this round -- the ones - * safe to actually act on. [allMatches] is every survivor's match this round, tentative ones - * included: [MidrollUpdater] needs that wider set to recognise a fresh discovery hit as the - * same still-unconfirmed candidate rather than minting a duplicate for the same segment. - */ - data class Result( - val candidates: List<Candidate>, - val detected: List<Detected>, - val allMatches: List<Detected>, - ) - - /** What checking one candidate against the episode decided. */ - private sealed interface Outcome { - data class Kept(val candidate: Candidate, val match: Detected?) : Outcome - data object Discarded : Outcome - } - - /** - * Checks every candidate in [candidates] against [fullClip] (a whole episode's decoded - * audio), independently. A candidate the episode does not confirm is dropped from the - * returned list entirely -- not weakened -- the same discard-on-mismatch discipline as - * [IntroOutroDetector]. - */ - fun check( - candidates: List<Candidate>, - episodeId: String, - fullClip: DoubleArray, - confirmThreshold: Int = CONFIRM_THRESHOLD, - ): Result { - val survivors = mutableListOf<Candidate>() - val detected = mutableListOf<Detected>() - val allMatches = mutableListOf<Detected>() - - for (candidate in candidates) { - when (val outcome = evaluate(candidate, episodeId, fullClip)) { - is Outcome.Discarded -> Unit - is Outcome.Kept -> { - survivors += outcome.candidate - val match = outcome.match ?: continue - allMatches += match - if (outcome.candidate.confirmedCount >= confirmThreshold) detected += match - } - } - } - - return Result(survivors, detected, allMatches) - } - - private fun evaluate(candidate: Candidate, episodeId: String, fullClip: DoubleArray): Outcome { - if (candidate.lastConfirmedEpisodeId == episodeId) return Outcome.Kept(candidate, match = null) - - val match = AudioCorrelation.bestMatch(candidate.referenceClip, fullClip) - if (match == null || match.score < MIN_SCORE) return Outcome.Discarded - - val bumped = candidate.copy(confirmedCount = candidate.confirmedCount + 1, lastConfirmedEpisodeId = episodeId) - val detected = Detected(bumped.id, match.position, candidate.referenceClip.size) - return Outcome.Kept(bumped, detected) - } -} diff --git a/app/src/main/java/org/terst/listen/fingerprint/MidrollScanner.kt b/app/src/main/java/org/terst/listen/fingerprint/MidrollScanner.kt deleted file mode 100644 index d69870c..0000000 --- a/app/src/main/java/org/terst/listen/fingerprint/MidrollScanner.kt +++ /dev/null @@ -1,79 +0,0 @@ -package org.terst.listen.fingerprint - -/** - * Finds every segment [a] and [b] share, at whatever position each recording happens to have it - * -- unlike [IntroOutroDetector], which only ever looks for one candidate anchored at the head or - * tail. A show's midroll ad breaks land at different timestamps in every episode, so discovering - * one means scanning the whole body of both episodes rather than a short fixed-position window. - * - * Single-resolution and pure: given whatever `a`/`b` it is handed, it does not know or care that - * the real caller runs it twice at different sample rates (see the coarse-to-fine composition in - * [MidrollUpdater]) -- that split exists purely so a hand-length scan of a full episode is fast - * enough to run on a phone, and has nothing to do with correctness. - */ -object MidrollScanner { - - /** A segment found at [positionInA] in `a` and [positionInB] in `b`, both in samples. */ - data class SharedSegment( - val positionInA: Int, - val positionInB: Int, - val lengthSamples: Int, - val score: Double, - ) - - /** - * Slides a [windowSamples]-long window across [a] every [stepSamples], keeping wherever it - * best matches somewhere in [b]. Hits are merged into one [SharedSegment] when they fall on - * the same diagonal (`positionInA - positionInB` within a step of each other) -- consecutive - * windows walking across the same real embedded segment, not two different ones. - */ - fun findSharedSegments( - a: DoubleArray, - b: DoubleArray, - windowSamples: Int, - stepSamples: Int, - minScore: Double, - ): List<SharedSegment> { - if (windowSamples <= 0 || windowSamples > a.size || windowSamples > b.size) return emptyList() - - val groups = mutableListOf<Group>() - var start = 0 - while (start + windowSamples <= a.size) { - val window = a.copyOfRange(start, start + windowSamples) - val match = AudioCorrelation.bestMatch(window, b) - if (match != null && match.score >= minScore) { - place(groups, Hit(aStart = start, bStart = match.position, score = match.score), windowSamples, stepSamples) - } - start += stepSamples - } - - return groups.map { - SharedSegment( - positionInA = it.aStart, - positionInB = it.bStart, - lengthSamples = it.aEnd - it.aStart, - score = it.bestScore, - ) - } - } - - private class Group(var aStart: Int, var aEnd: Int, var bStart: Int, var bEnd: Int, var bestScore: Double) - - private data class Hit(val aStart: Int, val bStart: Int, val score: Double) - - private fun place(groups: MutableList<Group>, hit: Hit, windowSamples: Int, tolerance: Int) { - val aEnd = hit.aStart + windowSamples - val bEnd = hit.bStart + windowSamples - val diagonal = hit.aStart - hit.bStart - val existing = groups.firstOrNull { kotlin.math.abs((it.aStart - it.bStart) - diagonal) <= tolerance } - if (existing == null) { - groups += Group(hit.aStart, aEnd, hit.bStart, bEnd, hit.score) - } else { - existing.aStart = minOf(existing.aStart, hit.aStart) - existing.aEnd = maxOf(existing.aEnd, aEnd) - existing.bStart = minOf(existing.bStart, hit.bStart) - existing.bEnd = maxOf(existing.bEnd, bEnd) - existing.bestScore = maxOf(existing.bestScore, hit.score) - } - } -} diff --git a/app/src/main/java/org/terst/listen/fingerprint/MidrollUpdater.kt b/app/src/main/java/org/terst/listen/fingerprint/MidrollUpdater.kt deleted file mode 100644 index 95dcd3e..0000000 --- a/app/src/main/java/org/terst/listen/fingerprint/MidrollUpdater.kt +++ /dev/null @@ -1,300 +0,0 @@ -package org.terst.listen.fingerprint - -import java.util.UUID -import org.terst.listen.data.EpisodeDao -import org.terst.listen.data.EpisodeEntity -import org.terst.listen.data.EpisodeMidrollEntity -import org.terst.listen.data.MidrollDao -import org.terst.listen.data.ShowMidrollEntity -import org.terst.listen.data.SubscriptionDao -import org.terst.listen.data.playbackDurationMs - -/** - * Runs [MidrollDetector] for every downloaded episode a show has not yet been checked against, - * and discovers new candidates via [MidrollScanner] when there is room and something to compare - * against. - * - * The one thing [FingerprintUpdater] never has to do that this does: decide *when* to spend the - * cost of a full-body scan. Comparing two whole episodes is expensive enough that it only ever - * happens between the show's single rolling "pending" episode and whatever is newest -- see - * [org.terst.listen.data.SubscriptionEntity.midrollPendingEpisodeId] for why no audio is - * persisted for it between runs. - */ -class MidrollUpdater( - private val subscriptions: SubscriptionDao, - private val episodes: EpisodeDao, - private val midrolls: MidrollDao, - private val clips: AudioClipSource, - private val clipStore: MidrollClipStore, - private val tuning: Tuning = Tuning(), -) { - - /** Every knob this class needs, grouped so the constructor stays a fixed, readable size no - * matter how many of them a test needs to override at once. */ - data class Tuning( - /** How much to shrink audio before the coarse discovery pass. 1 disables it entirely. */ - val coarseFactor: Int = DEFAULT_COARSE_FACTOR, - /** The coarse-pass window, in already-decimated samples -- see [MidrollScanner]. */ - val discoverySamples: Int = DEFAULT_DISCOVERY_SAMPLES, - val maxCandidates: Int = DEFAULT_MAX_CANDIDATES, - val confirmThreshold: Int = DEFAULT_CONFIRM_THRESHOLD, - /** - * The longest episode a full-body scan will attempt. - * - * A scan holds the whole episode decoded in memory, so an episode's length is also an - * allocation: an hour of 8kHz mono is 28.8 million doubles, 230MB, on a phone with no - * largeHeap. Asking for that threw OutOfMemoryError inside [AudioClipSource.decodeFull], - * where `runCatching` swallowed it -- and the episode was then marked checked forever, so - * midroll detection could never find anything on any episode of normal length and said - * nothing about why. - * - * An over-budget episode is skipped *before* anything is spent, and deliberately left - * unchecked, so that lifting this limit (by decoding candidate ranges on demand instead - * of the whole body at once) picks those episodes up rather than finding them written - * off. - */ - val maxDecodableMs: Long = DEFAULT_MAX_DECODABLE_MS, - val newId: () -> String = { UUID.randomUUID().toString() }, - val now: () -> Long = System::currentTimeMillis, - ) - - private val coarseFactor get() = tuning.coarseFactor - private val discoverySamples get() = tuning.discoverySamples - private val maxCandidates get() = tuning.maxCandidates - private val confirmThreshold get() = tuning.confirmThreshold - private val newId get() = tuning.newId - private val now get() = tuning.now - - suspend fun processShow(subscriptionId: String) { - val show = subscriptions.getById(subscriptionId) ?: return - var pendingEpisodeId = show.midrollPendingEpisodeId - - // Over-budget episodes are dropped here rather than skipped inside the loop: nothing is - // spent on them and they stay unchecked, so lifting the limit picks them up again -- - // see Tuning.maxDecodableMs. - val scannable = midrolls.getUncheckedDownloaded(subscriptionId).filterNot(::exceedsDecodeBudget) - - for (episode in scannable) { - val decoded = clips.decodeFull(episode) - if (decoded == null) { - midrolls.setMidrollCheckedAtMs(episode.id, now()) - continue - } - - val stored = midrolls.getCandidates(subscriptionId) - val result = MidrollDetector.check( - load(subscriptionId, stored), - episode.id, - decoded.samples, - confirmThreshold, - ) - persist(subscriptionId, stored, result, decoded.sampleRate) - for (detected in result.detected) applyDetected(episode, decoded, detected) - - if (result.candidates.size < maxCandidates) { - pendingEpisodeId?.takeIf { it != episode.id }?.let { pendingId -> - discoverAgainstPending(subscriptionId, pendingId, episode, decoded, result.allMatches) - } - pendingEpisodeId = episode.id - } - - midrolls.setMidrollCheckedAtMs(episode.id, now()) - } - - if (pendingEpisodeId != show.midrollPendingEpisodeId) { - subscriptions.update(show.copy(midrollPendingEpisodeId = pendingEpisodeId)) - } - } - - /** - * Whether this episode is longer than a whole-body decode can hold. - * - * An unknown length is not over budget: it is the case [AudioClipSource.decodeFull] already - * declines for want of a size to allocate, and routing it through here would change what - * happens to it. - */ - private fun exceedsDecodeBudget(episode: EpisodeEntity): Boolean { - val durationMs = episode.playbackDurationMs ?: return false - return durationMs > tuning.maxDecodableMs - } - - private suspend fun discoverAgainstPending( - subscriptionId: String, - pendingEpisodeId: String, - newEpisode: EpisodeEntity, - newClip: DecodedClip, - alreadyMatchedInNewEpisode: List<MidrollDetector.Detected>, - ) { - val pendingEpisode = episodes.getById(pendingEpisodeId) ?: return - val pendingClip = clips.decodeFull(pendingEpisode) ?: return - - // The same real segment surfaces from a fresh discovery pass every time the pending - // pointer rolls forward -- e2 vs e3 finds the same ad e1 vs e2 already tracked. - // Recognised by *where* it landed in the new episode, not by re-correlating clip - // content: two coarse passes over the same segment do not reliably extract the same - // sub-range of it, so a content comparison between them can score low even though both - // come from the one real ad break -- position overlap is what is actually stable - // between runs. - val tolerance = discoverySamples.toLong() * coarseFactor - val undiscovered = discover(pendingClip.samples, newClip.samples).filterNot { segment -> - alreadyMatchedInNewEpisode.any { existing -> - rangesOverlap( - segment.positionInB - tolerance, segment.positionInB + segment.lengthInB + tolerance, - existing.positionInClip.toLong(), existing.positionInClip.toLong() + existing.lengthSamples, - ) - } - } - - for (segment in undiscovered) { - val candidateId = newId() - val path = clipStore.write(subscriptionId, candidateId, segment.referenceClip) ?: continue - midrolls.upsertCandidate( - ShowMidrollEntity( - subscriptionId = subscriptionId, - id = candidateId, - referenceClipPath = path, - sampleRate = newClip.sampleRate, - confirmedCount = SEEDED_CONFIRM_COUNT, - lastConfirmedEpisodeId = newEpisode.id, - updatedAtMs = now(), - ) - ) - } - } - - private fun rangesOverlap(aStart: Long, aEnd: Long, bStart: Long, bEnd: Long) = aStart < bEnd && bStart < aEnd - - private data class DiscoveredSegment(val referenceClip: DoubleArray, val positionInB: Long, val lengthInB: Long) - - /** - * Coarse-to-fine: [MidrollScanner] runs once on decimated copies to find candidate - * alignments cheaply, then each candidate is re-sliced from the *native*-resolution audio and - * re-checked with a single [AudioCorrelation.bestMatch] -- both for a trustworthy score and - * to reject anything the decimation made look like a match but is not. See the desktop - * prototype findings this composition is based on for why the coarse pass alone is not - * trusted: it is a performance shortcut, not where correctness comes from. - */ - private fun discover(a: DoubleArray, b: DoubleArray): List<DiscoveredSegment> { - val coarseA = Decimate.byAveraging(a, coarseFactor) - val coarseB = Decimate.byAveraging(b, coarseFactor) - val step = (discoverySamples / 2).coerceAtLeast(1) - val hits = MidrollScanner.findSharedSegments(coarseA, coarseB, discoverySamples, step, COARSE_MIN_SCORE) - return hits.mapNotNull { extractSegment(it, a, b) } - } - - /** - * Re-slices one coarse hit at native resolution and verifies it with a single - * [AudioCorrelation.bestMatch] -- both for a trustworthy score and to reject anything the - * decimation made look like a match but is not. Null covers every way that can fail: an empty - * slice at either end, or a score that does not hold up at full resolution. - */ - private fun extractSegment(hit: MidrollScanner.SharedSegment, a: DoubleArray, b: DoubleArray): DiscoveredSegment? { - val aStart = (hit.positionInA.toLong() * coarseFactor).coerceIn(0, a.size.toLong()).toInt() - val aEnd = ((hit.positionInA.toLong() + hit.lengthSamples) * coarseFactor).coerceIn(0, a.size.toLong()).toInt() - if (aEnd - aStart <= 0) return null - val query = a.copyOfRange(aStart, aEnd).let { - if (it.size > MAX_REFERENCE_SAMPLES) it.copyOf(MAX_REFERENCE_SAMPLES) else it - } - - val margin = discoverySamples.toLong() * coarseFactor - val bStart = ((hit.positionInB.toLong() * coarseFactor) - margin).coerceIn(0, b.size.toLong()).toInt() - val bEnd = ((hit.positionInB.toLong() + hit.lengthSamples) * coarseFactor + margin) - .coerceIn(0, b.size.toLong()).toInt() - if (bEnd - bStart < query.size) return null - val target = b.copyOfRange(bStart, bEnd) - - val match = AudioCorrelation.bestMatch(query, target) ?: return null - if (match.score < FINE_MIN_SCORE) return null - return DiscoveredSegment(query, (bStart + match.position).toLong(), query.size.toLong()) - } - - private suspend fun persist( - subscriptionId: String, - stored: List<ShowMidrollEntity>, - result: MidrollDetector.Result, - sampleRate: Int, - ) { - val surviving = result.candidates.associateBy { it.id } - val pathsById = stored.associate { it.id to it.referenceClipPath } - - for (row in stored) { - if (row.id in surviving) continue - // The file goes with the row. Nothing else would ever reclaim it: the candidate id - // is the only thing that knew where it was. - midrolls.deleteCandidate(subscriptionId, row.id) - clipStore.delete(row.referenceClipPath) - } - - for (candidate in result.candidates) { - // Rewriting the audio would be pointless -- a surviving candidate's reference clip - // is the one it was created with; only its confirmation count moves. - val path = pathsById[candidate.id] ?: continue - midrolls.upsertCandidate( - ShowMidrollEntity( - subscriptionId = subscriptionId, - id = candidate.id, - referenceClipPath = path, - sampleRate = sampleRate, - confirmedCount = candidate.confirmedCount, - lastConfirmedEpisodeId = candidate.lastConfirmedEpisodeId, - updatedAtMs = now(), - ) - ) - } - } - - private suspend fun applyDetected( - episode: EpisodeEntity, - clip: DecodedClip, - detected: MidrollDetector.Detected, - ) { - val startMs = clip.startMs + detected.positionInClip.toLong() * MS_PER_SECOND / clip.sampleRate - val endMs = startMs + detected.lengthSamples.toLong() * MS_PER_SECOND / clip.sampleRate - midrolls.upsertRange(EpisodeMidrollEntity(episode.id, detected.candidateId, startMs, endMs)) - } - - /** Null when the clip file has gone, which makes the candidate untrackable -- see [load]. */ - private suspend fun toDetectorCandidate(entity: ShowMidrollEntity): MidrollDetector.Candidate? { - val clip = clipStore.read(entity.referenceClipPath) ?: return null - return MidrollDetector.Candidate( - id = entity.id, - referenceClip = clip, - confirmedCount = entity.confirmedCount, - lastConfirmedEpisodeId = entity.lastConfirmedEpisodeId, - ) - } - - /** - * The candidates whose audio is still on disk, dropping the rows whose file has gone. - * - * A missing file is not recoverable -- there is nothing left to match against -- so the row - * goes too rather than being carried forward as a candidate that can never confirm. - */ - private suspend fun load(subscriptionId: String, stored: List<ShowMidrollEntity>) = - stored.mapNotNull { row -> - toDetectorCandidate(row) ?: run { - midrolls.deleteCandidate(subscriptionId, row.id) - null - } - } - - private companion object { - const val DEFAULT_COARSE_FACTOR = 8 - const val DEFAULT_DISCOVERY_SAMPLES = 20_000 // 20s at the coarse (post-decimation) rate - const val DEFAULT_MAX_CANDIDATES = 4 - const val DEFAULT_CONFIRM_THRESHOLD = 3 - - /** - * Twenty minutes, which at 8kHz mono is 9.6 million doubles -- about 77MB, the most a - * background worker can reasonably hold on a phone with no largeHeap. Not a detection - * threshold: it is the ceiling of the current in-memory decode, and it rises when that - * decode learns to fetch candidate ranges on demand. - */ - const val DEFAULT_MAX_DECODABLE_MS = 20L * 60 * 1000 - const val SEEDED_CONFIRM_COUNT = 2 - const val COARSE_MIN_SCORE = 0.7 - const val FINE_MIN_SCORE = 0.6 - const val MAX_REFERENCE_SAMPLES = 180 * 8_000 // 3 minutes at 8kHz, a generous cap - const val MS_PER_SECOND = 1_000L - } -} diff --git a/app/src/main/java/org/terst/listen/fingerprint/MonoResampler.kt b/app/src/main/java/org/terst/listen/fingerprint/MonoResampler.kt deleted file mode 100644 index a98d2ab..0000000 --- a/app/src/main/java/org/terst/listen/fingerprint/MonoResampler.kt +++ /dev/null @@ -1,134 +0,0 @@ -package org.terst.listen.fingerprint - -/** - * Turns a decoder's interleaved PCM16 output into mono at a fixed rate, a buffer at a time. - * - * The reason this is streaming rather than three passes over three arrays: a full-episode scan's - * decode used to buffer every interleaved sample at the source's native rate, then every mono - * frame, and only then resample. For a 13-minute stereo 44.1kHz episode that is 145MB and 289MB - * live at once to produce 6MB of output -- on a phone with no largeHeap. It threw - * OutOfMemoryError inside a `runCatching` that swallowed it, which is why midroll detection - * never ran on anything and never said why. Here the peak allocation is the output. - * - * Pure, so it is the part of [MediaCodecClipSource] that can actually be tested; what is left - * there is MediaCodec plumbing and nothing else. - * - * The interpolation is deliberately unchanged -- naive linear, no anti-aliasing filter. It was - * measured at 0.9991 correlation against a proper ffmpeg/swresample decode of the same audio - * (`docs/detection-findings.md`), so it was never the weak link, and a rewrite that both moved - * the allocation and changed the arithmetic would be untraceable if detection got worse. - */ -class MonoResampler( - private val inputRate: Int, - private val channels: Int, - private val targetRate: Int, -) { - - private val ratio = targetRate.toDouble() / inputRate - - private var out = DoubleArray(INITIAL_CAPACITY) - private var outCount = 0 - - /** Mono frames of the buffer being consumed, reused across calls to keep churn down. */ - private var frames = DoubleArray(0) - - private var framesAccepted = 0 - - /** - * The last mono frame accepted, kept because an output sample interpolates between two - * source frames and those two can straddle a decoder buffer boundary. One frame is all that - * is ever needed: output positions only move forward. - */ - private var previousFrame = 0.0 - - /** A frame split across two decoder buffers. Nothing guarantees they arrive frame-aligned. */ - private val splitFrame = ShortArray(channels.coerceAtLeast(1)) - private var splitCount = 0 - - /** Consumes [count] interleaved samples from [interleaved] starting at [offset]. */ - fun accept(interleaved: ShortArray, offset: Int, count: Int) { - if (count <= 0 || channels <= 0) return - - val accepted = toMonoFrames(interleaved, offset, count) - if (accepted == 0) return - - emitWhatIsReadable(start = framesAccepted, available = accepted) - framesAccepted += accepted - previousFrame = frames[accepted - 1] - } - - /** - * The resampled mono audio. - * - * Length is `(frames * ratio)` truncated, exactly as the batch implementation computed it, so - * that the two cannot disagree about where the audio ends. - */ - fun finish(): DoubleArray { - if (framesAccepted == 0) return DoubleArray(0) - - // Whatever is left reads past the final frame, which clamps to it -- so every remaining - // output is that frame, whatever its fractional position would have been. - val total = (framesAccepted * ratio).toInt() - while (outCount < total) append(previousFrame) - - return out.copyOf(outCount) - } - - /** - * Averages [count] interleaved samples down into [frames], completing any frame the previous - * buffer left split and keeping any this one does. Returns how many whole frames resulted. - */ - private fun toMonoFrames(source: ShortArray, offset: Int, count: Int): Int { - val total = splitCount + count - val whole = total / channels - if (frames.size < whole) frames = DoubleArray(whole) - - // The split frame's samples come first: together with this buffer they are one stream. - fun sampleAt(index: Int): Short = - if (index < splitCount) splitFrame[index] else source[offset + index - splitCount] - - var read = 0 - for (frame in 0 until whole) { - var sum = 0.0 - repeat(channels) { sum += sampleAt(read++) } - frames[frame] = sum / channels - } - - // Safe to overwrite while reading: with a whole frame produced, `read` is already past - // everything held in splitFrame; with none, this copies those samples onto themselves. - val leftover = total - whole * channels - for (i in 0 until leftover) splitFrame[i] = sampleAt(read + i) - splitCount = leftover - - return whole - } - - /** - * Emits every output sample whose two source frames are both readable now -- the newest - * buffer's frames, plus [previousFrame] sitting immediately before them. - */ - private fun emitWhatIsReadable(start: Int, available: Int) { - val lastReadable = start + available - 1 - while (true) { - val sourcePosition = outCount / ratio - val index = sourcePosition.toInt() - if (index + 1 > lastReadable) return - - // Output positions advance monotonically and everything readable from earlier - // buffers was already emitted, so `index` is never further back than previousFrame. - val before = if (index < start) previousFrame else frames[index - start] - val after = if (index + 1 < start) previousFrame else frames[index + 1 - start] - val fraction = sourcePosition - index - append(before * (1 - fraction) + after * fraction) - } - } - - private fun append(value: Double) { - if (outCount == out.size) out = out.copyOf(out.size * 2) - out[outCount++] = value - } - - private companion object { - const val INITIAL_CAPACITY = 8_192 - } -} diff --git a/app/src/main/java/org/terst/listen/fingerprint/PcmCodec.kt b/app/src/main/java/org/terst/listen/fingerprint/PcmCodec.kt deleted file mode 100644 index 2865442..0000000 --- a/app/src/main/java/org/terst/listen/fingerprint/PcmCodec.kt +++ /dev/null @@ -1,41 +0,0 @@ -package org.terst.listen.fingerprint - -import kotlin.math.roundToInt - -/** - * Converts between the `DoubleArray` samples [AudioCorrelation] and [IntroOutroDetector] work - * with and the compact 16-bit PCM bytes actually stored in [org.terst.listen.data.ShowFingerprintEntity]. - * - * 16-bit rather than the raw doubles: a stored clip is read back on every future episode of a - * show for as long as it lives, and doubles would be four times the size for no precision this - * feature needs -- the decoded audio itself never had more than 16 bits of resolution to begin - * with. - */ -object PcmCodec { - - private const val MAX_SAMPLE = Short.MAX_VALUE.toDouble() - private const val MIN_SAMPLE = Short.MIN_VALUE.toDouble() - private const val BYTES_PER_SAMPLE = 2 - private const val BITS_PER_BYTE = 8 - private const val BYTE_MASK = 0xFF - - fun encode(samples: DoubleArray): ByteArray { - val bytes = ByteArray(samples.size * BYTES_PER_SAMPLE) - for (i in samples.indices) { - val clamped = samples[i].roundToInt().coerceIn(MIN_SAMPLE.toInt(), MAX_SAMPLE.toInt()) - bytes[i * BYTES_PER_SAMPLE] = (clamped and BYTE_MASK).toByte() - bytes[i * BYTES_PER_SAMPLE + 1] = ((clamped shr BITS_PER_BYTE) and BYTE_MASK).toByte() - } - return bytes - } - - fun decode(bytes: ByteArray): DoubleArray { - val samples = DoubleArray(bytes.size / BYTES_PER_SAMPLE) - for (i in samples.indices) { - val low = bytes[i * BYTES_PER_SAMPLE].toInt() and BYTE_MASK - val high = bytes[i * BYTES_PER_SAMPLE + 1].toInt() - samples[i] = ((high shl BITS_PER_BYTE) or low).toShort().toDouble() - } - return samples - } -} diff --git a/app/src/main/java/org/terst/listen/fingerprint/SkipSummary.kt b/app/src/main/java/org/terst/listen/fingerprint/SkipSummary.kt index 66b5fcc..2f0545d 100644 --- a/app/src/main/java/org/terst/listen/fingerprint/SkipSummary.kt +++ b/app/src/main/java/org/terst/listen/fingerprint/SkipSummary.kt @@ -63,6 +63,22 @@ data class EpisodeSkips( */ object SkipSummary { + /** + * How many episodes must agree before a candidate is trusted, which the readout says out loud + * as "2 of 3". + * + * Lives here because this is the only thing left that needs it. It used to be + * IntroOutroDetector.CONFIRM_THRESHOLD and MidrollDetector.CONFIRM_THRESHOLD -- one each, + * both three -- and those classes were the on-device detector, deleted once detection moved + * to the server. + * + * It still describes a real rule, just somebody else's: the server applies the same threshold + * (`skips.DefaultConfirmThreshold`, also three, "the safety mechanism, not a knob"). The two + * were checked against each other before this moved. If the server's ever changes, this line + * is what makes the app describe it wrongly, and there is no compiler to notice. + */ + const val CONFIRM_THRESHOLD = 3 + private const val MS_PER_SECOND = 1000L private const val SECONDS_PER_MINUTE = 60 private const val MINUTES_PER_HOUR = 60 diff --git a/app/src/main/java/org/terst/listen/fingerprint/StoredClip.kt b/app/src/main/java/org/terst/listen/fingerprint/StoredClip.kt deleted file mode 100644 index 0c39e71..0000000 --- a/app/src/main/java/org/terst/listen/fingerprint/StoredClip.kt +++ /dev/null @@ -1,24 +0,0 @@ -package org.terst.listen.fingerprint - -/** - * How much decoded audio may be put in a database row. - * - * Android hands every Room read through a CursorWindow, which refuses a row larger than 2MB -- - * on the way *out*. An oversized blob inserts cleanly and then throws - * `SQLiteBlobTooBigException` the next time anything reads it, which is how the intro/outro - * half of Tier 3 shipped unable to get past a show's second episode. - * - * The intro/outro reference clip is the only decoded audio still kept in a row, and it goes - * through [fits] before it is written. Midroll candidates outgrew this entirely and live in - * files -- see [MidrollClipStore]. - */ -object StoredClip { - - /** Android's own cap on a row read through a CursorWindow. */ - const val CURSOR_WINDOW_LIMIT_BYTES = 2 * 1024 * 1024 - - /** Half of it, so the columns alongside the blob can never tip an accepted clip over. */ - const val SAFE_BYTES = CURSOR_WINDOW_LIMIT_BYTES / 2 - - fun fits(bytes: Int): Boolean = bytes <= SAFE_BYTES -} diff --git a/app/src/main/java/org/terst/listen/ui/ShowSettingsViewModel.kt b/app/src/main/java/org/terst/listen/ui/ShowSettingsViewModel.kt index 064cb61..6ed7bf6 100644 --- a/app/src/main/java/org/terst/listen/ui/ShowSettingsViewModel.kt +++ b/app/src/main/java/org/terst/listen/ui/ShowSettingsViewModel.kt @@ -12,8 +12,6 @@ import org.terst.listen.data.FingerprintKind import org.terst.listen.data.Graph import org.terst.listen.data.ShowTier import org.terst.listen.data.UiGraph -import org.terst.listen.fingerprint.IntroOutroDetector -import org.terst.listen.fingerprint.MidrollDetector import org.terst.listen.fingerprint.SkipSummary /** @@ -163,15 +161,15 @@ class ShowSettingsViewModel(app: Application, private val graph: UiGraph) : Andr _detection.value = ShowDetection( intro = SkipSummary.candidateState( graph.fingerprints.get(subscriptionId, FingerprintKind.INTRO), - IntroOutroDetector.CONFIRM_THRESHOLD, + SkipSummary.CONFIRM_THRESHOLD, ), outro = SkipSummary.candidateState( graph.fingerprints.get(subscriptionId, FingerprintKind.OUTRO), - IntroOutroDetector.CONFIRM_THRESHOLD, + SkipSummary.CONFIRM_THRESHOLD, ), midrolls = SkipSummary.midrollState( graph.midrolls.getCandidates(subscriptionId), - MidrollDetector.CONFIRM_THRESHOLD, + SkipSummary.CONFIRM_THRESHOLD, ), ) } diff --git a/app/src/test/java/org/terst/listen/ReachabilityTest.kt b/app/src/test/java/org/terst/listen/ReachabilityTest.kt index 10d0409..a720f57 100644 --- a/app/src/test/java/org/terst/listen/ReachabilityTest.kt +++ b/app/src/test/java/org/terst/listen/ReachabilityTest.kt @@ -164,11 +164,13 @@ class ReachabilityTest { /** * Methods on classes, which the object check structurally cannot see. * - * The blind spot cost something real. SleepTimer is a class, and `armForEndOfEpisode`, - * `shouldStopAtEpisodeEnd` and `extendBy` sat written, documented and unit-tested with no - * caller anywhere -- so "stop at the end of this episode" did not exist in the app, and every - * duration in the sleep dialog reset the timer rather than extending it, which is the one - * thing `extendBy`'s own comment says not to do. Tests passing said nothing. + * The blind spot cost something real, twice over. SleepTimer is a class, and + * `armForEndOfEpisode`, `shouldStopAtEpisodeEnd` and `extendBy` sat written, documented and + * unit-tested with no caller anywhere -- so "stop at the end of this episode" did not exist in + * the app, and every duration in the sleep dialog reset the timer rather than extending it, + * which is the one thing `extendBy`'s own comment says not to do. It then found the whole of + * on-device detection, about two thousand lines shipped in every APK since the detector moved + * to the server. Tests passing said nothing about either. * * Matched on `.name(` across all of main with declarations stripped, the same way the DAO * check works and for the same reason: a call on a class names the *instance*, not the class, @@ -231,25 +233,11 @@ class ReachabilityTest { /** * Class methods deliberately not called from production. Every entry needs a reason. * - * Both entries are the same fact: **on-device detection is dead code.** It moved to the - * server (see CLAUDE.md -- "FingerprintWorker and MidrollWorker are gone from the app"), and - * the workers went while the classes they drove stayed. Nothing constructs either updater; - * every reference to them from outside their own files is a KDoc cross-reference. - * - * That reaches further than these two methods. IntroOutroDetector, AudioCorrelation, - * MidrollScanner, MidrollDetector, MidrollClipStore, MediaCodecClipSource, Decimate, Fft, - * MonoResampler, PcmCodec, StoredClip and BreakTrack are all only reachable through them -- - * roughly two thousand lines of app code and fourteen test files, shipped in every APK. - * SkipSummary and BreakPopup are the only things in `fingerprint/` still live. - * - * Allowlisted rather than deleted because deleting a subsystem is the owner's call, and the - * audio-fingerprint work cost weeks and may yet be wanted back. Recorded here so it cannot - * be lost again: this is the entry to delete when that decision is made. + * Empty, and it has been once before: it held FingerprintUpdater.process and + * MidrollUpdater.processShow, the entry points to on-device detection, from the day this + * check was widened to classes until the day that subsystem was deleted. */ - private val allowedClassMethods = setOf( - "FingerprintUpdater.process", - "MidrollUpdater.processShow", - ) + private val allowedClassMethods = emptySet<String>() @Test fun `every helper object function has a caller`() { diff --git a/app/src/test/java/org/terst/listen/data/FakeDaos.kt b/app/src/test/java/org/terst/listen/data/FakeDaos.kt index f3b9a23..fc2c558 100644 --- a/app/src/test/java/org/terst/listen/data/FakeDaos.kt +++ b/app/src/test/java/org/terst/listen/data/FakeDaos.kt @@ -352,14 +352,6 @@ class FakeMidrollDao : MidrollDao { override suspend fun getCandidates(subscriptionId: String) = candidateRows.filter { it.subscriptionId == subscriptionId } - override suspend fun upsertCandidate(candidate: ShowMidrollEntity) { - candidateRows.removeAll { it.subscriptionId == candidate.subscriptionId && it.id == candidate.id } - candidateRows += candidate - } - - override suspend fun deleteCandidate(subscriptionId: String, id: String) { - candidateRows.removeAll { it.subscriptionId == subscriptionId && it.id == id } - } override suspend fun getRanges(episodeId: String) = rangeRows.filter { it.episodeId == episodeId } @@ -372,15 +364,6 @@ class FakeMidrollDao : MidrollDao { rangeRows.removeAll { it.episodeId == episodeId } } - override suspend fun getUncheckedDownloaded(subscriptionId: String, downloaded: DownloadState) = - episodeRows.filter { - it.subscriptionId == subscriptionId && it.downloadState == downloaded && it.midrollCheckedAtMs == null - }.sortedBy { it.downloadedAtMs ?: 0 } - - override suspend fun setMidrollCheckedAtMs(id: String, ms: Long) { - val index = episodeRows.indexOfFirst { it.id == id } - if (index >= 0) episodeRows[index] = episodeRows[index].copy(midrollCheckedAtMs = ms) - } override suspend fun getAllMidrollPendingEpisodeIds() = subscriptionRows.mapNotNull { it.midrollPendingEpisodeId }.distinct() diff --git a/app/src/test/java/org/terst/listen/data/StoredClipSizeTest.kt b/app/src/test/java/org/terst/listen/data/StoredClipSizeTest.kt deleted file mode 100644 index 93ca377..0000000 --- a/app/src/test/java/org/terst/listen/data/StoredClipSizeTest.kt +++ /dev/null @@ -1,52 +0,0 @@ -package org.terst.listen.data - -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test -import org.terst.listen.fingerprint.FingerprintUpdater -import org.terst.listen.fingerprint.MediaCodecClipSource -import org.terst.listen.fingerprint.StoredClip - -/** - * Guards the bug class that took Tier 3 down: a blob big enough to write but too big to read. - * - * Android reads every Room row through a CursorWindow, and a row over 2MB throws - * `SQLiteBlobTooBigException` on the way *out*. Nothing complains on insert, nothing complains - * at build time, and the unit tests were perfectly happy -- it only surfaced on a device, as a - * hard crash the moment a show's second episode was fingerprinted. - * - * Decoded audio is the only thing in this schema anywhere near that size, so these pin the two - * clips that get persisted against the limit rather than trusting anyone to remember it. - */ -class StoredClipSizeTest { - - private fun encodedBytes(seconds: Int, sampleRate: Int) = seconds * sampleRate * 2 - - /** The reference clip is short by design, and is the one fingerprint blob that is kept. */ - @Test - fun aStoredFingerprintReferenceClipFitsInARow() { - val bytes = encodedBytes(FingerprintUpdater.DEFAULT_REFERENCE_SECONDS, FingerprintUpdater.DEFAULT_SAMPLE_RATE) - - assertTrue("reference clip is $bytes bytes", StoredClip.fits(bytes)) - } - - /** - * The whole head/tail window emphatically does not fit, which is why only the pending - * *episode id* is stored and the audio re-decoded. If this ever starts passing, the window - * shrank and the comment explaining that indirection needs revisiting -- it does not mean - * the clip may now be stored. - */ - @Test - fun awholeHeadWindowDoesNotFitWhichIsWhyItIsNeverStored() { - val bytes = encodedBytes(MediaCodecClipSource.CLIP_SECONDS, MediaCodecClipSource.TARGET_SAMPLE_RATE) - - assertFalse("head window is $bytes bytes", StoredClip.fits(bytes)) - } - - /** Half the hard limit, so the rest of the row can never tip an accepted clip over it. */ - @Test - fun theSafeSizeLeavesHeadroomUnderTheHardLimit() { - assertTrue(StoredClip.SAFE_BYTES * 2 <= StoredClip.CURSOR_WINDOW_LIMIT_BYTES) - assertFalse(StoredClip.fits(StoredClip.SAFE_BYTES + 1)) - } -} diff --git a/app/src/test/java/org/terst/listen/data/UnsubscribeTest.kt b/app/src/test/java/org/terst/listen/data/UnsubscribeTest.kt index 494b6a9..330b592 100644 --- a/app/src/test/java/org/terst/listen/data/UnsubscribeTest.kt +++ b/app/src/test/java/org/terst/listen/data/UnsubscribeTest.kt @@ -85,14 +85,19 @@ class UnsubscribeTest { assertEquals(listOf("s2"), subscriptions.rows.map { it.id }) } + /** + * Clips left by the on-device midroll detector, which no longer exists. The files do: a + * phone that ran that version still has them, and this is the only thing that clears them. + */ @Test fun `midroll clips go too`() = runTest { show("s1") - val clip = clipStore.write("s1", "c1", DoubleArray(10))!! + val clip = File(File(dir, "midrolls/s1").apply { mkdirs() }, "c1.pcm") + .apply { writeBytes(ByteArray(16)) } unsubscriber.unsubscribe("s1") - assertFalse(File(clip).exists()) + assertFalse(clip.exists()) } /** An episode that was never downloaded has no file, and that is not an error. */ diff --git a/app/src/test/java/org/terst/listen/fingerprint/AudioCorrelationTest.kt b/app/src/test/java/org/terst/listen/fingerprint/AudioCorrelationTest.kt deleted file mode 100644 index 0d078cd..0000000 --- a/app/src/test/java/org/terst/listen/fingerprint/AudioCorrelationTest.kt +++ /dev/null @@ -1,197 +0,0 @@ -package org.terst.listen.fingerprint - -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Test - -/** - * Ground truth for these fixtures was computed with numpy/scipy (the same tool the desktop - * prototype was validated against) and pinned as literals, so this test catches a Kotlin port - * disagreeing with real signal-processing math -- not just disagreeing with itself. - * - * The self-test (a signal matched against itself) is the one that caught a real bug in the - * Python prototype: an earlier version centered the target on its *global* mean before - * convolving, instead of each window's own local mean, and reported a bogus 0.98 "match" that - * plain correlation put at -0.02. Porting that mistake into Kotlin unnoticed is exactly what - * this guards against. - */ -class AudioCorrelationTest { - - // A 40-sample synthetic "sting": a sine tone plus noise, seed-fixed in the desktop - // prototype so this is reproducible from Python if it ever needs regenerating. - private val sting = doubleArrayOf( - 32.486907, 220.126458, 400.928498, 474.895065, 484.816274, 285.530555, 154.554067, - -134.881970, -325.180547, -472.495529, -467.112278, -452.694747, -238.809930, -7.681087, - 255.036975, 389.494108, 492.905873, 449.950953, 332.405604, 131.314136, -141.670216, - -308.666855, -449.476307, -486.304550, -393.474814, -246.036143, -2.457805, 213.646197, - 406.134171, 506.961546, 453.674906, 323.626259, 105.914378, -136.561945, -344.986252, - -467.761413, -518.700644, -406.803619, -199.165542, 14.840883, - ) - - // 300 samples of noise with the sting embedded (plus a little extra noise on the copy) at - // index 120. - private val targetWithMatch = doubleArrayOf( - -20.837892, -2.813341, -106.809805, 82.013540, -89.671779, -42.087368, 25.144071, - -62.264404, -52.897611, -45.450381, 27.572702, 114.610401, 2.076970, -55.896272, - 26.952916, -29.807985, -0.956525, 58.750061, -37.393547, 0.451263, -43.905395, - -7.821709, 12.828523, -49.438952, -16.941098, -11.809202, -31.882751, -59.380614, - -71.060861, -7.674760, -13.452848, 111.568339, -121.738379, 5.636325, 18.522227, - 67.981693, 25.092860, -42.210685, 0.000488, 27.117629, -15.675410, 38.550587, - -93.404533, 86.559233, 73.383901, -16.783867, 30.567039, 2.398530, -41.456764, - 4.385511, 50.018294, -19.054626, -18.783471, -3.723538, 21.674817, 63.918962, - -31.733965, 25.419812, 10.805800, -92.930619, -20.965824, -6.616445, -1.978512, - 16.300172, -102.016152, 2.312776, -33.883779, -71.971951, 26.214822, 36.763979, - -32.662513, 42.122814, -19.075824, 3.324450, -54.936947, 79.224353, -132.972473, - -4.572631, 34.755980, -101.673327, -9.473463, -3.860933, 41.235150, 62.410646, - -20.194613, -69.225933, 68.361771, 60.894282, -23.100267, 17.544425, 19.093312, - 28.313772, 10.210399, 70.334812, -86.897975, 52.041198, 19.023599, -10.856763, - 58.676575, -117.180160, 58.076075, 19.303902, -56.656664, 21.654628, -15.204322, - 129.264743, 91.766636, 22.034494, -35.962692, -29.170730, -16.252481, -28.011725, - -45.112303, -29.548614, -13.808975, -25.844195, -34.929497, -46.444596, 127.521912, - -73.658662, 41.430050, 222.309007, 401.410985, 465.577601, 483.429333, 283.756760, - 154.140360, -138.016974, -325.399638, -474.881619, -473.681602, -448.271635, - -234.403340, 0.866778, 255.287143, 387.470721, 490.179073, 442.218566, 337.317441, - 125.808798, -147.595448, -309.695104, -442.045565, -485.120969, -398.593740, - -249.601109, 0.668420, 212.843631, 402.289990, 505.811393, 457.400188, 333.506812, - 99.693761, -139.694030, -349.005082, -479.856829, -523.319604, -411.922998, - -193.545653, 14.181312, -62.771361, 46.908184, -23.417313, -62.736015, 6.241182, - 37.825107, 12.071981, 24.871282, 205.434631, 41.056044, 76.588016, -99.292289, - 18.252676, 38.704102, -18.223955, -43.798974, 19.826008, -15.730872, -29.687779, - 57.475028, 66.778308, 15.131467, -22.711393, 25.718536, 41.472922, 31.531098, - -72.668217, -16.900889, 17.956667, 31.111021, 48.039097, 37.918517, -56.715924, - -35.371044, -61.071458, 90.223832, 9.020490, 27.658214, 51.651453, -16.450122, - -57.550147, -21.326124, -7.407360, 75.071846, 43.479910, -54.354529, 33.211071, - 36.744233, -53.068287, -5.425841, -92.520199, 16.524403, -15.784661, -67.500105, - -34.908550, 11.997560, -27.647472, 14.976341, 27.633185, -42.022151, -15.613533, - 107.233904, 6.055279, -42.341438, 3.023122, -66.929444, 56.637304, 18.515242, - 54.290320, 45.108970, 19.514822, 48.775471, 9.578682, -33.110451, -51.175749, - -22.408741, -125.272907, 91.299723, -85.703371, -3.831978, -65.878363, -101.279680, - -4.112269, -15.233329, -7.986207, 27.447328, -30.918774, 18.939723, 25.662572, - -16.742206, -14.175976, 26.921213, 2.862547, 7.954424, -118.720134, 2.925997, - 18.827296, -6.773988, 16.795420, 95.218795, 4.268222, 33.266714, -42.499775, - -42.617090, -23.999256, -50.982455, -0.380057, -46.691533, -8.749842, -71.857172, - -82.610015, -33.783089, -53.353356, -32.646557, -30.604738, -17.563123, 52.273899, - 68.450801, 36.267663, -17.973723, 74.847589, -76.555555, -101.168197, 13.398629, - -0.110322, -6.964594, 1.628273, -82.028011, -57.834959, 61.701734, 51.409245, - -36.093986, 96.657848, -53.539816, -28.569080, 14.621603, -59.749995, -24.396527, - -8.653558, -19.767320, - ) - - // 300 samples of pure noise -- no embedded sting anywhere. - private val targetNoMatch = doubleArrayOf( - 2.528085, 24.997567, -49.795447, 34.679925, -20.915076, -79.228862, -32.385338, - 29.928759, 16.612502, -57.373832, 30.933484, -4.399346, 21.253620, 16.612657, - -57.840813, 17.549858, -30.344364, 77.348966, 36.167080, 2.306778, -49.149583, - 2.721637, 7.994647, -60.447408, 111.168011, 19.714761, 84.617886, -55.640608, - 81.787377, -68.048280, -32.561292, 27.122565, 2.400312, -117.903682, -55.279202, - 41.891818, 104.393543, 45.742048, -13.810168, 39.825595, -57.189929, 25.495989, - -67.373015, -0.468005, -6.535232, 40.104331, -15.148198, 60.100129, -9.837264, - 41.826435, 39.330114, -92.043793, 1.877374, 1.796403, -38.936996, 8.970536, - -72.776716, 27.809261, 25.488943, 15.022277, 123.829208, 17.617170, 3.373550, - -36.613235, 14.857061, -48.088840, 63.590931, -32.382227, 7.923477, 99.504151, - 58.209378, 12.133008, 68.996005, -2.727935, 39.761697, 0.954498, -45.271907, - 21.513567, 46.732503, -17.305094, -54.856094, -26.409803, -118.988764, -30.384185, - -53.764505, 101.120253, -28.243765, -77.146453, 43.542089, -8.760526, 2.430150, - 9.432310, 10.465674, -18.722246, 47.734930, 26.162383, -24.790926, -8.573230, - -47.218430, 14.043234, 36.912356, 32.537662, 30.737031, -6.328430, 78.943712, - -10.359084, 1.503354, -16.808992, 4.063189, -12.026209, 101.608966, 21.292336, - -84.148244, -21.694319, -70.811377, 43.994950, 68.729662, -22.552145, 30.676631, - -10.507828, 20.402908, 86.619925, -17.472398, -61.656841, 35.913719, 25.827982, - 33.690075, 51.322748, -34.057421, -98.300736, 52.045318, -20.432516, -60.370023, - -58.414514, 56.418667, 9.041666, 15.145102, 42.519344, -33.845047, -56.255730, - -42.647564, -55.228731, -14.554790, -44.548787, 1.931761, -24.042457, -3.998974, - 32.489517, 70.063845, 77.796896, 5.746109, 10.618514, -25.419011, 50.299100, - 76.589543, -29.680090, 38.205283, -16.478327, -8.555750, 9.214238, -20.058841, - 44.755219, -74.792618, -34.769845, -5.970572, -52.241185, 18.031899, -75.745215, - 2.758879, -72.013244, -7.262337, 25.403665, 110.178201, 53.881717, 80.862060, - 3.398610, 105.786792, -12.062934, -12.609875, -64.104141, 11.112587, 52.680020, - -31.692907, -18.530109, -23.177249, -16.572628, -29.519169, -67.501986, 69.048406, - -98.587654, -46.354110, 17.187073, 50.578087, -10.430041, -14.162315, 2.473827, - -13.065602, 43.837124, -24.143934, -49.270923, -23.633141, 4.813397, 61.460670, - 79.993876, -19.970714, -73.209178, -69.193314, 106.266108, -20.143489, 22.895311, - 95.864243, -34.005812, -9.728700, -29.029759, -54.795800, 39.987777, 53.284988, - -93.686922, 49.085533, -11.798356, 9.181316, 9.893450, -65.326934, -15.543546, - -49.545661, 95.762579, 109.292408, 4.201670, -58.947801, 57.802803, -9.500092, - 87.272225, 98.435969, 83.645321, -19.762973, -49.903739, -33.152500, -4.817262, - -46.190367, 59.668426, 32.873208, -1.698264, -20.019649, 32.465724, 68.920978, - -96.489767, 11.693680, -15.023265, -37.211485, -17.062800, 6.725856, 93.478276, - 2.852858, -21.488338, -61.359507, 38.699963, -37.593538, -5.687254, 35.654340, - 2.618530, -19.311432, -8.635993, 44.917451, -0.981811, 12.133660, 21.142423, - -16.726968, 16.396956, -13.840458, 56.692166, 25.090513, -86.516897, -10.367697, - 55.458729, -10.643295, -21.125871, 49.684189, -54.131309, 3.251745, 29.508159, - 42.596381, -51.667508, -62.310604, 74.727850, -38.921091, -4.494221, 121.561200, - -23.262437, -110.103229, 139.340180, 50.628967, 17.939541, 138.856734, -3.763288, - 72.296643, 0.622999, -13.782231, 31.625723, 44.219391, 64.118368, - ) - - @Test - fun `a signal matched against itself scores near 1 at the only valid position`() { - val match = AudioCorrelation.bestMatch(sting, sting) - assertTrue("expected a match", match != null) - assertEquals(0, match!!.position) - assertEquals(1.0, match.score, 1e-6) - } - - @Test - fun `finds the embedded sting at its real position with the ground-truth score`() { - val match = AudioCorrelation.bestMatch(sting, targetWithMatch) - assertTrue("expected a match", match != null) - assertEquals(120, match!!.position) - assertEquals(0.9998959789695058, match.score, 1e-6) - } - - @Test - fun `an unrelated target scores far below the match, matching numpy's ground truth`() { - val match = AudioCorrelation.bestMatch(sting, targetNoMatch) - assertTrue("expected a best-effort match even with no real signal", match != null) - assertEquals(0.3672490793439344, match!!.score, 1e-6) - } - - @Test - fun `a query longer than the target has nowhere valid to match`() { - assertNull(AudioCorrelation.bestMatch(targetWithMatch, sting)) - } - - @Test - fun `silence in the query has no meaningful correlation with anything`() { - assertNull(AudioCorrelation.bestMatch(DoubleArray(40), targetWithMatch)) - } - - /** - * The target's transform is the expensive half of a correlation, and inside a window search - * the target never changes -- see [IntroOutroDetector]'s candidate search, which correlates - * every window of one clip against the *same* other clip. Preparing it once has to give - * bit-for-bit what the one-shot call gives, or the search would quietly change its answers - * as a side effect of going faster. - */ - @Test - fun `a prepared target gives the same match as a one-shot call`() { - val oneShot = AudioCorrelation.bestMatch(sting, targetWithMatch)!! - val reused = AudioCorrelation.bestMatch(sting, AudioCorrelation.prepareTarget(targetWithMatch, sting.size))!! - - assertEquals(oneShot.position, reused.position) - assertEquals(oneShot.score, reused.score, 1e-12) - } - - /** The whole point: one preparation, many queries, each scoring as it would alone. */ - @Test - fun `a prepared target serves several queries, each scoring as it would alone`() { - val prepared = AudioCorrelation.prepareTarget(targetWithMatch, sting.size) - - for (start in listOf(0, 40, 120, 200)) { - val query = targetWithMatch.copyOfRange(start, start + sting.size) - val alone = AudioCorrelation.bestMatch(query, targetWithMatch)!! - val viaPrepared = AudioCorrelation.bestMatch(query, prepared)!! - assertEquals("position at $start", alone.position, viaPrepared.position) - assertEquals("score at $start", alone.score, viaPrepared.score, 1e-12) - } - } - - /** A prepared target is sized for one query length; anything else is a programming error. */ - @Test - fun `a prepared target rejects a query of the wrong length`() { - val prepared = AudioCorrelation.prepareTarget(targetWithMatch, sting.size) - assertNull(AudioCorrelation.bestMatch(DoubleArray(sting.size + 1), prepared)) - } -} diff --git a/app/src/test/java/org/terst/listen/fingerprint/DecimateTest.kt b/app/src/test/java/org/terst/listen/fingerprint/DecimateTest.kt deleted file mode 100644 index c23d5de..0000000 --- a/app/src/test/java/org/terst/listen/fingerprint/DecimateTest.kt +++ /dev/null @@ -1,35 +0,0 @@ -package org.terst.listen.fingerprint - -import org.junit.Assert.assertArrayEquals -import org.junit.Assert.assertThrows -import org.junit.Test - -class DecimateTest { - - @Test - fun `averages fixed-size blocks`() { - val input = doubleArrayOf(0.0, 4.0, 2.0, 6.0, 10.0, 2.0) - val out = Decimate.byAveraging(input, factor = 2) - assertArrayEquals(doubleArrayOf(2.0, 4.0, 6.0), out, 1e-9) - } - - @Test - fun `a trailing partial block is dropped, not padded`() { - val input = doubleArrayOf(0.0, 4.0, 2.0, 6.0, 10.0) - val out = Decimate.byAveraging(input, factor = 2) - assertArrayEquals(doubleArrayOf(2.0, 4.0), out, 1e-9) - } - - @Test - fun `factor 1 is the identity`() { - val input = doubleArrayOf(1.0, 2.0, 3.0) - assertArrayEquals(input, Decimate.byAveraging(input, factor = 1), 1e-9) - } - - @Test - fun `factor below 1 is rejected`() { - assertThrows(IllegalArgumentException::class.java) { - Decimate.byAveraging(doubleArrayOf(1.0), factor = 0) - } - } -} diff --git a/app/src/test/java/org/terst/listen/fingerprint/FingerprintUpdaterTest.kt b/app/src/test/java/org/terst/listen/fingerprint/FingerprintUpdaterTest.kt deleted file mode 100644 index 7dd7d1d..0000000 --- a/app/src/test/java/org/terst/listen/fingerprint/FingerprintUpdaterTest.kt +++ /dev/null @@ -1,131 +0,0 @@ -package org.terst.listen.fingerprint - -import kotlin.math.sin -import kotlin.random.Random -import kotlinx.coroutines.test.runTest -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Test -import org.terst.listen.data.EpisodeEntity -import org.terst.listen.data.FakeEpisodeDao -import org.terst.listen.data.FakeShowFingerprintDao -import org.terst.listen.data.FingerprintKind - -class FingerprintUpdaterTest { - - private lateinit var episodes: FakeEpisodeDao - private lateinit var fingerprints: FakeShowFingerprintDao - private lateinit var clips: FakeClipSource - private lateinit var updater: FingerprintUpdater - - @Before - fun setUp() { - episodes = FakeEpisodeDao() - fingerprints = FakeShowFingerprintDao().apply { episodeRows = episodes.rows } - clips = FakeClipSource() - updater = FingerprintUpdater(fingerprints, episodes, clips, referenceLengthSamples = 40) - } - - private fun noise(seed: Int, n: Int) = Random(seed).let { r -> DoubleArray(n) { r.nextDouble(-50.0, 50.0) } } - private fun sting(seed: Int, n: Int): DoubleArray { - val r = Random(seed) - return DoubleArray(n) { sin(it * 0.3) * 400 + r.nextDouble(-20.0, 20.0) } - } - - private fun embed(base: DoubleArray, segment: DoubleArray, at: Int): DoubleArray { - val out = base.copyOf() - segment.forEachIndexed { i, v -> out[at + i] = v } - return out - } - - private fun episode(id: String, durationMs: Long = 300_000) = EpisodeEntity( - id = id, - subscriptionId = "show1", - guid = id, - title = "Episode $id", - mediaUrl = "https://cdn/$id.mp3", - mediaType = "audio/mpeg", - discoveredAtMs = 0, - localPath = "/fake/$id.mp3", - durationMs = durationMs, - ).also { episodes.rows += it } - - private val theSting = sting(seed = 1, n = 40) - - @Test - fun `nothing happens for an episode with no local file yet`() = runTest { - val ep = EpisodeEntity( - id = "e1", subscriptionId = "show1", guid = "e1", title = "t", - mediaUrl = "u", mediaType = "audio/mpeg", discoveredAtMs = 0, localPath = null, - ) - updater.process(ep) - assertTrue(fingerprints.rows.isEmpty()) - } - - @Test - fun `three episodes with a consistent intro confirm and set detectedIntroMs on the third`() = runTest { - clips.heads["e1"] = embed(noise(1, 300), theSting, 50) - clips.heads["e2"] = embed(noise(2, 300), theSting, 80) - clips.heads["e3"] = embed(noise(3, 300), theSting, 30) - - updater.process(episode("e1")) - updater.process(episode("e2")) - updater.process(episode("e3")) - - val fp = fingerprints.rows.single { it.kind == FingerprintKind.INTRO } - assertEquals(3, fp.confirmedCount) - - // Position 30 + the sting's own 40 samples, at the fake 8kHz sample rate. - val e3 = episodes.rows.single { it.id == "e3" } - assertEquals((30 + 40) * 1000L / 8000, e3.detectedIntroMs) - } - - @Test - fun `an episode that does not share the tentative candidate is not confirmed`() = runTest { - clips.heads["e1"] = embed(noise(1, 300), theSting, 50) - clips.heads["e2"] = embed(noise(2, 300), theSting, 80) - clips.heads["e3"] = noise(99, 300) // unrelated -- the Darknet Diaries case - - updater.process(episode("e1")) - updater.process(episode("e2")) - updater.process(episode("e3")) - - val fp = fingerprints.rows.single { it.kind == FingerprintKind.INTRO } - assertEquals("the false candidate must be discarded, not confirmed", 0, fp.confirmedCount) - assertNull(episodes.rows.single { it.id == "e3" }.detectedIntroMs) - } - - @Test - fun `outro position is converted to a duration from the episode end`() = runTest { - // 300-sample tail clip standing in for "the last N seconds"; the fake clip source - // reports it started at 250_000ms into a 300_000ms episode. - clips.tails["e1"] = embed(noise(1, 300), theSting, 100) - clips.tails["e2"] = embed(noise(2, 300), theSting, 120) - clips.tails["e3"] = embed(noise(3, 300), theSting, 90) - - updater.process(episode("e1", durationMs = 300_000)) - updater.process(episode("e2", durationMs = 300_000)) - updater.process(episode("e3", durationMs = 300_000)) - - val e3 = episodes.rows.single { it.id == "e3" } - val matchedStartMs = clips.tailStartMs + 90 * 1000L / 8000 - assertEquals(300_000 - matchedStartMs, e3.detectedOutroMs) - } - - /** A fake decode source: pre-seeded clips per episode id, sample rate fixed for the test. */ - private class FakeClipSource : AudioClipSource { - val heads = mutableMapOf<String, DoubleArray>() - val tails = mutableMapOf<String, DoubleArray>() - val tailStartMs = 250_000L - - override suspend fun decodeHead(episode: EpisodeEntity): DecodedClip? = - heads[episode.id]?.let { DecodedClip(it, sampleRate = 8_000, startMs = 0) } - - override suspend fun decodeTail(episode: EpisodeEntity): DecodedClip? = - tails[episode.id]?.let { DecodedClip(it, sampleRate = 8_000, startMs = tailStartMs) } - - override suspend fun decodeFull(episode: EpisodeEntity): DecodedClip? = null - } -} diff --git a/app/src/test/java/org/terst/listen/fingerprint/IntroOutroDetectorScaleTest.kt b/app/src/test/java/org/terst/listen/fingerprint/IntroOutroDetectorScaleTest.kt deleted file mode 100644 index a8fcaa5..0000000 --- a/app/src/test/java/org/terst/listen/fingerprint/IntroOutroDetectorScaleTest.kt +++ /dev/null @@ -1,171 +0,0 @@ -package org.terst.listen.fingerprint - -import kotlin.math.sin -import kotlin.random.Random -import kotlin.system.measureTimeMillis -import org.junit.Assert.assertArrayEquals -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Test - -/** - * The guard whose absence let the detector ship unusable. - * - * [IntroOutroDetectorTest] proves the state machine on 300-sample clips. Production hands it - * 1,920,000 -- a 240-second window at 8kHz, 6,400 times larger -- and nothing anywhere measured - * what that costs. It cost minutes per episode: the candidate search correlated every one of 117 - * window positions against the whole of the other clip, re-transforming that unchanged clip - * every time. `FingerprintWorker` is a plain WorkManager worker, so Android stopped it at ten - * minutes, before it had persisted anything, and WorkManager re-ran it -- which is why Peter's - * phone ran hot and why no show ever got past its first processed episode. - * - * So these tests assert cost, at the real sizes, with real constants. The budget is deliberately - * generous: this runs on whatever machine happens to build the app, and a phone is slower again. - * It is not a benchmark, it is a tripwire -- the failure it exists to catch is minutes, not - * milliseconds. - */ -class IntroOutroDetectorScaleTest { - - private companion object { - const val SAMPLE_RATE = 8_000 - /** [MediaCodecClipSource.CLIP_SECONDS], the window production actually decodes. */ - const val CLIP_SECONDS = 240 - const val CLIP_SAMPLES = SAMPLE_RATE * CLIP_SECONDS - - /** [FingerprintUpdater.DEFAULT_REFERENCE_SECONDS] at [FingerprintUpdater.DEFAULT_SAMPLE_RATE]. */ - const val REFERENCE_SAMPLES = 8 * SAMPLE_RATE - - /** - * One episode's worth of candidate search. Both ends of an episode run inside the same - * ten-minute worker, so a single end has to come in far enough under that to leave room - * for the other end, two or four MediaCodec decodes, and a slower CPU than this one. - */ - const val BUDGET_MS = 60_000L - } - - private fun noise(seed: Int, n: Int) = Random(seed).let { r -> DoubleArray(n) { r.nextDouble(-50.0, 50.0) } } - - /** A tonal sting, the shape a real show's intro music has at 8kHz. */ - private fun sting(seed: Int, n: Int): DoubleArray { - val r = Random(seed) - return DoubleArray(n) { sin(it * 0.03) * 400 + r.nextDouble(-20.0, 20.0) } - } - - private fun embed(base: DoubleArray, segment: DoubleArray, at: Int): DoubleArray { - val out = base.copyOf() - segment.forEachIndexed { i, v -> out[at + i] = v } - return out - } - - /** The show's recurring intro, long enough that a reference window lands wholly inside it. */ - private val theShowsSting = sting(seed = 100, n = REFERENCE_SAMPLES * 2) - - /** - * Two episodes, the sting at a different offset in each -- the dynamic-pre-roll case - * `docs/detection-findings.md` measured on real Up First audio, where the intro landed - * anywhere between 44 and 94 seconds in. - */ - private fun episodeClip(seed: Int, stingAtSeconds: Int, clipSeconds: Int = CLIP_SECONDS) = - embed(noise(seed, clipSeconds * SAMPLE_RATE), theShowsSting, stingAtSeconds * SAMPLE_RATE) - - @Test - fun `a production-sized candidate search finds the sting inside the worker's budget`() { - val first = episodeClip(seed = 1, stingAtSeconds = 44) - val second = episodeClip(seed = 2, stingAtSeconds = 94) - - val waiting = IntroOutroDetector.check(FingerprintState(), "ep1", first, REFERENCE_SAMPLES) - assertTrue("first episode has nothing to compare against", waiting is FingerprintUpdate.Waiting) - - lateinit var update: FingerprintUpdate - val elapsed = measureTimeMillis { - update = IntroOutroDetector.check(waiting.state, "ep2", second, REFERENCE_SAMPLES) - } - - assertTrue( - "expected a candidate from two episodes sharing an intro, got $update", - update is FingerprintUpdate.Tentative, - ) - assertTrue( - "candidate search took ${elapsed}ms against a ${BUDGET_MS}ms budget -- a worker " + - "that cannot finish inside WorkManager's ten minutes never persists anything", - elapsed < BUDGET_MS, - ) - } - - /** - * The confirming episode's path, which runs once a reference exists and is the one that - * actually writes a skip offset. Cheap by construction -- a single correlation -- and pinned - * here so it stays that way. - */ - @Test - fun `confirming against an existing reference stays cheap at production size`() { - val reference = theShowsSting.copyOf(REFERENCE_SAMPLES) - val state = FingerprintState( - referenceClip = reference, - confirmedCount = 2, - lastConfirmedEpisodeId = "ep2", - ) - - lateinit var update: FingerprintUpdate - val elapsed = measureTimeMillis { - update = IntroOutroDetector.check(state, "ep3", episodeClip(seed = 3, stingAtSeconds = 61), REFERENCE_SAMPLES) - } - - assertTrue("expected the third episode to confirm, got $update", update is FingerprintUpdate.Confirmed) - assertEquals(61 * SAMPLE_RATE, (update as FingerprintUpdate.Confirmed).positionInClip) - assertTrue("single confirmation took ${elapsed}ms", elapsed < BUDGET_MS) - } - - /** - * Full resolution has to stay reachable, because that is what the current behaviour is - * defined by: a coarse factor of 1 means "search exactly as before". Having the knob proves - * the speed-up is a resolution choice and not a different algorithm wearing its name. - * - * Deliberately on a shorter clip than the budget tests. At the full 240 seconds this takes - * 137 seconds -- the cost the coarse pass exists to avoid -- and paying it on every run of - * the suite to re-prove something a 40-second clip proves just as well is the kind of thing - * that gets a test deleted rather than kept. - */ - @Test - fun `a coarse factor of one still finds the sting`() { - val first = episodeClip(seed = 4, stingAtSeconds = 8, clipSeconds = 40) - val second = episodeClip(seed = 5, stingAtSeconds = 24, clipSeconds = 40) - val tuning = IntroOutroDetector.Tuning(coarseFactor = 1) - - val waiting = IntroOutroDetector.check(FingerprintState(), "ep1", first, REFERENCE_SAMPLES, tuning = tuning) - val update = IntroOutroDetector.check(waiting.state, "ep2", second, REFERENCE_SAMPLES, tuning = tuning) - - assertTrue("expected a candidate at full resolution, got $update", update is FingerprintUpdate.Tentative) - } - - /** - * The coarse pass has to agree with full resolution about *where* the sting is, not just - * that there is one. A faster search that found a different segment would still report - * Tentative and would still be wrong -- and nothing downstream could tell. - */ - @Test - fun `the coarse search picks the same segment full resolution does`() { - val first = episodeClip(seed = 6, stingAtSeconds = 8, clipSeconds = 40) - val second = episodeClip(seed = 7, stingAtSeconds = 24, clipSeconds = 40) - - fun referenceWith(tuning: IntroOutroDetector.Tuning): DoubleArray { - val waiting = IntroOutroDetector.check(FingerprintState(), "ep1", first, REFERENCE_SAMPLES, tuning = tuning) - return IntroOutroDetector.check(waiting.state, "ep2", second, REFERENCE_SAMPLES, tuning = tuning) - .state.referenceClip!! - } - - val atFullResolution = referenceWith(IntroOutroDetector.Tuning(coarseFactor = 1)) - val viaCoarsePass = referenceWith(IntroOutroDetector.Tuning()) - - // Both searches step across the clip on the same native grid -- full resolution by - // win/4, the coarse pass by (win/factor)/4 scaled back up by factor, which is the same - // 16,000 samples -- so the window they pick is comparable sample for sample. Identical - // contents means the same position, and the position is what a skip offset comes from. - assertArrayEquals( - "coarse and full-resolution searches disagree about which segment is the sting", - atFullResolution, - viaCoarsePass, - 0.0, - ) - } -} diff --git a/app/src/test/java/org/terst/listen/fingerprint/IntroOutroDetectorTest.kt b/app/src/test/java/org/terst/listen/fingerprint/IntroOutroDetectorTest.kt deleted file mode 100644 index a9dfdbc..0000000 --- a/app/src/test/java/org/terst/listen/fingerprint/IntroOutroDetectorTest.kt +++ /dev/null @@ -1,191 +0,0 @@ -package org.terst.listen.fingerprint - -import kotlin.math.sin -import kotlin.random.Random -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Test - -/** - * These fixtures are synthetic, unlike [AudioCorrelationTest]'s numpy-derived ones -- this - * layer is the state machine (waiting / tentative / confirmed / discard-on-mismatch), not the - * correlation math itself, which is already proven. What matters is reproducing the shapes the - * desktop prototype actually found in real shows: - * - * - NPR/Tangle: the same sting recurs across every episode, though not always at the exact same - * position (a variable-length lead-in) -- [confirms after three episodes]. - * - Darknet Diaries: two temporally-close episodes agreed on a segment that a third, - * independently-fetched episode did not share at all -- it was a dynamically-inserted ad, not - * the show's real intro. [discards a tentative candidate on the first disagreement]. - */ -class IntroOutroDetectorTest { - - private fun noise(seed: Int, n: Int) = Random(seed).let { r -> DoubleArray(n) { r.nextDouble(-50.0, 50.0) } } - - private fun sting(seed: Int, n: Int): DoubleArray { - val r = Random(seed) - return DoubleArray(n) { sin(it * 0.3) * 400 + r.nextDouble(-20.0, 20.0) } - } - - private fun embed(base: DoubleArray, segment: DoubleArray, at: Int): DoubleArray { - val out = base.copyOf() - segment.forEachIndexed { i, v -> out[at + i] = v } - return out - } - - private val theShowsSting = sting(seed = 100, n = 40) - - @Test - fun `the first episode of a show has nothing to compare against yet`() { - val update = IntroOutroDetector.check( - FingerprintState(), "ep1", embed(noise(1, 300), theShowsSting, 50), REFERENCE_LENGTH, - ) - assertTrue(update is FingerprintUpdate.Waiting) - assertEquals("ep1", update.state.pendingEpisodeId) - } - - @Test - fun `a second episode sharing the sting becomes a tentative candidate, not yet trusted`() { - var state = FingerprintState() - state = IntroOutroDetector.check(state, "ep1", embed(noise(1, 300), theShowsSting, 50), REFERENCE_LENGTH).state - val update = IntroOutroDetector.check( - state, "ep2", embed(noise(2, 300), theShowsSting, 80), REFERENCE_LENGTH, - ) - assertTrue(update is FingerprintUpdate.Tentative) - assertEquals(2, update.state.confirmedCount) - } - - @Test - fun `a third independent episode agreeing promotes the candidate to confirmed`() { - var state = FingerprintState() - state = IntroOutroDetector.check(state, "ep1", embed(noise(1, 300), theShowsSting, 50), REFERENCE_LENGTH).state - state = IntroOutroDetector.check(state, "ep2", embed(noise(2, 300), theShowsSting, 80), REFERENCE_LENGTH).state - val update = IntroOutroDetector.check( - state, "ep3", embed(noise(3, 300), theShowsSting, 30), REFERENCE_LENGTH, - ) - assertTrue(update is FingerprintUpdate.Confirmed) - val confirmed = update as FingerprintUpdate.Confirmed - assertEquals(3, confirmed.state.confirmedCount) - assertEquals(30, confirmed.positionInClip) - } - - @Test - fun `episodes keep confirming past the threshold, each with its own matched position`() { - var state = FingerprintState() - state = IntroOutroDetector.check(state, "ep1", embed(noise(1, 300), theShowsSting, 50), REFERENCE_LENGTH).state - state = IntroOutroDetector.check(state, "ep2", embed(noise(2, 300), theShowsSting, 80), REFERENCE_LENGTH).state - state = IntroOutroDetector.check(state, "ep3", embed(noise(3, 300), theShowsSting, 30), REFERENCE_LENGTH).state - val update = IntroOutroDetector.check( - state, "ep4", embed(noise(4, 300), theShowsSting, 100), REFERENCE_LENGTH, - ) - assertTrue(update is FingerprintUpdate.Confirmed) - assertEquals(100, (update as FingerprintUpdate.Confirmed).positionInClip) - assertEquals(4, update.state.confirmedCount) - } - - /** - * The Darknet Diaries scenario: two episodes fetched close together agree on a segment - * (a rotating ad, though this detector has no way to know that) -- a third, independent - * episode simply doesn't share it. It must be discarded, not averaged away or kept as a - * weaker version of the same candidate. - */ - @Test - fun `a candidate that a third episode does not share is discarded, not weakened`() { - var state = FingerprintState() - state = IntroOutroDetector.check(state, "ep1", embed(noise(1, 300), theShowsSting, 50), REFERENCE_LENGTH).state - state = IntroOutroDetector.check(state, "ep2", embed(noise(2, 300), theShowsSting, 80), REFERENCE_LENGTH).state - - // ep3 shares nothing with the tentative candidate -- pure, unrelated noise. - val update = IntroOutroDetector.check(state, "ep3", noise(99, 300), REFERENCE_LENGTH) - - assertTrue("a mismatch must not be reported as any kind of match", update is FingerprintUpdate.Waiting) - assertEquals(0, update.state.confirmedCount) - assertNull(update.state.referenceClip) - // ep3 becomes the new pending basis, exactly as if it were a fresh first episode. - assertEquals("ep3", update.state.pendingEpisodeId) - } - - /** The same episode must never be counted toward confirmation twice. */ - @Test - fun `reprocessing the same episode id is a no-op`() { - var state = FingerprintState() - state = IntroOutroDetector.check(state, "ep1", embed(noise(1, 300), theShowsSting, 50), REFERENCE_LENGTH).state - state = IntroOutroDetector.check(state, "ep2", embed(noise(2, 300), theShowsSting, 80), REFERENCE_LENGTH).state - state = IntroOutroDetector.check(state, "ep3", embed(noise(3, 300), theShowsSting, 30), REFERENCE_LENGTH).state - - val replay = IntroOutroDetector.check( - state, "ep3", embed(noise(3, 300), theShowsSting, 30), REFERENCE_LENGTH, - ) - assertEquals(state.confirmedCount, replay.state.confirmedCount) - } - - /** Reprocessing an episode id while merely tentative must report Tentative, not silently - * promote or demote it. */ - @Test - fun `reprocessing an episode id while only tentative reports tentative again`() { - var state = FingerprintState() - state = IntroOutroDetector.check(state, "ep1", embed(noise(1, 300), theShowsSting, 50), REFERENCE_LENGTH).state - state = IntroOutroDetector.check(state, "ep2", embed(noise(2, 300), theShowsSting, 80), REFERENCE_LENGTH).state - - val replay = IntroOutroDetector.check( - state, "ep2", embed(noise(2, 300), theShowsSting, 80), REFERENCE_LENGTH, - ) - assertTrue(replay is FingerprintUpdate.Tentative) - } - - /** Reprocessing the very first episode, before any candidate exists, must still report - * Waiting rather than mistaking "nothing yet" for a mismatch. */ - @Test - fun `reprocessing the first episode id before any candidate exists reports waiting`() { - val state = IntroOutroDetector.check( - FingerprintState(), "ep1", embed(noise(1, 300), theShowsSting, 50), REFERENCE_LENGTH, - ).state - - val replay = IntroOutroDetector.check( - state, "ep1", embed(noise(1, 300), theShowsSting, 50), REFERENCE_LENGTH, - ) - assertTrue(replay is FingerprintUpdate.Waiting) - } - - /** The very first two episodes a show has can simply share nothing -- this is the same - * "no signal here" case as a later mismatch, just before any candidate ever existed. */ - @Test - fun `the first two episodes sharing nothing leaves the second as the new basis`() { - var state = FingerprintState() - state = IntroOutroDetector.check(state, "ep1", noise(1, 300), REFERENCE_LENGTH).state - - val update = IntroOutroDetector.check(state, "ep2", noise(2, 300), REFERENCE_LENGTH) - - assertTrue(update is FingerprintUpdate.Waiting) - assertEquals("ep2", update.state.pendingEpisodeId) - assertNull(update.state.referenceClip) - } - - /** A raised threshold means a third agreeing episode is not yet enough -- proving - * confirmation genuinely depends on the threshold rather than being hardcoded to 3. */ - @Test - fun `a raised threshold keeps a third agreeing episode merely tentative`() { - val threshold = IntroOutroDetector.Tuning(confirmThreshold = 4) - var state = FingerprintState() - state = IntroOutroDetector.check( - state, "ep1", embed(noise(1, 300), theShowsSting, 50), REFERENCE_LENGTH, threshold, - ).state - state = IntroOutroDetector.check( - state, "ep2", embed(noise(2, 300), theShowsSting, 80), REFERENCE_LENGTH, threshold, - ).state - - val update = IntroOutroDetector.check( - state, "ep3", embed(noise(3, 300), theShowsSting, 30), REFERENCE_LENGTH, threshold, - ) - - assertTrue(update is FingerprintUpdate.Tentative) - assertEquals(3, update.state.confirmedCount) - } - - private companion object { - /** Matches theShowsSting's own length -- this layer tests the state machine, not - * real-world window sizing, which the caller (a future FingerprintWorker) decides. */ - const val REFERENCE_LENGTH = 40 - } -} diff --git a/app/src/test/java/org/terst/listen/fingerprint/MidrollClipStoreTest.kt b/app/src/test/java/org/terst/listen/fingerprint/MidrollClipStoreTest.kt index b648491..5fa463e 100644 --- a/app/src/test/java/org/terst/listen/fingerprint/MidrollClipStoreTest.kt +++ b/app/src/test/java/org/terst/listen/fingerprint/MidrollClipStoreTest.kt @@ -3,23 +3,22 @@ package org.terst.listen.fingerprint import java.io.File import kotlinx.coroutines.test.runTest import org.junit.After -import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse -import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test /** - * Midroll reference clips live in files, not in the database. + * Clearing out the clips on-device midroll detection left behind. * - * A database row cannot carry one: a real ad break runs minutes, and a CursorWindow refuses a - * row over 2MB on the way out. Capping the clip to fit was the first attempt and Peter shot it - * down for the right reason -- Tangle and The Daily Show wrap their breaks in theme music, so - * sixty seconds would clip the music off the front or back of the thing being matched. + * The store used to write and read them too, and those halves went with the detector when it + * moved to the server. The files did not: a phone that ran that version still has three minutes + * of 8kHz PCM per candidate per show sitting in its data directory, and unsubscribing is the one + * moment anything knows they can go. * - * [FileMidrollClipStore] is measured because it is ordinary file IO with no Android in it, not - * a framework adapter. + * Which means the directory layout is now a *compatibility* concern rather than this class's own + * choice -- it has to keep finding what a writer that no longer exists put there, so the + * sanitising is pinned here by writing the old layout by hand rather than through the store. */ class MidrollClipStoreTest { @@ -37,67 +36,45 @@ class MidrollClipStoreTest { dir.deleteRecursively() } - private fun clip(n: Int) = DoubleArray(n) { (it % 200 - 100).toDouble() } + /** The layout the deleted FileMidrollClipStore.write produced: <root>/<safe show>/<safe id>.pcm */ + private fun existingClip(showDir: String, candidate: String): File = + File(File(dir, showDir).apply { mkdirs() }, "$candidate.pcm").apply { writeBytes(ByteArray(64)) } @Test - fun `a clip survives a round trip`() = runTest { - val samples = clip(1000) - - val path = store.write("s1", "c1", samples)!! - - assertEquals(samples.toList(), store.read(path)!!.toList()) - } - - /** Far past what a row could hold -- the whole point of moving to files. */ - @Test - fun `a clip much larger than a database row round trips intact`() = runTest { - val threeMinutes = clip(180 * 8000) - - val path = store.write("s1", "big", threeMinutes)!! - - assertTrue("${File(path).length()} bytes", File(path).length() > StoredClip.CURSOR_WINDOW_LIMIT_BYTES) - assertEquals(threeMinutes.size, store.read(path)!!.size) - } - - @Test - fun `reading a clip that is gone returns null rather than throwing`() = runTest { - val path = store.write("s1", "c1", clip(10))!! - File(path).delete() - - assertNull(store.read(path)) - } - - @Test - fun `deleting a clip removes its file`() = runTest { - val path = store.write("s1", "c1", clip(10))!! + fun `dropping a show removes every clip it owns and no others`() = runTest { + val mine = existingClip("s1", "c1") + val alsoMine = existingClip("s1", "c2") + val someoneElses = existingClip("s2", "c1") - store.delete(path) + store.deleteAllFor("s1") - assertFalse(File(path).exists()) + assertFalse(mine.exists()) + assertFalse(alsoMine.exists()) + assertTrue(someoneElses.exists()) } /** - * Unsubscribing has to take the clips with it. Nothing else ever would -- the database rows - * cascade away and would leave the files behind with no row left pointing at them. + * Show ids come from feeds, so one carrying separators was sanitised on the way in and has + * to be sanitised the same way on the way out, or its clips are never found again. */ @Test - fun `dropping a show removes every clip it owns and no others`() = runTest { - val mine = store.write("s1", "c1", clip(10))!! - val alsoMine = store.write("s1", "c2", clip(10))!! - val someoneElses = store.write("s2", "c1", clip(10))!! + fun `a show id containing path separators finds the directory it was written to`() = runTest { + val written = existingClip("______etc", "c1") - store.deleteAllFor("s1") + store.deleteAllFor("../../etc") - assertFalse(File(mine).exists()) - assertFalse(File(alsoMine).exists()) - assertTrue(File(someoneElses).exists()) + assertFalse("the sanitising must match what the old writer used", written.exists()) } - /** Ids come from feeds and a candidate id is generated, but neither may escape the directory. */ + /** And must not wander outside the store while doing it. */ @Test - fun `a show id containing path separators cannot write outside the store`() = runTest { - val path = store.write("../../etc", "c1", clip(10))!! + fun `a traversing id cannot delete outside the store`() = runTest { + val outside = File(dir.parentFile, "not-ours-${dir.name}").apply { mkdirs() } + File(outside, "keep.pcm").writeBytes(ByteArray(8)) + + store.deleteAllFor("../${outside.name}") - assertTrue(path, File(path).canonicalPath.startsWith(dir.canonicalPath)) + assertTrue("deleting must stay inside the store", File(outside, "keep.pcm").exists()) + outside.deleteRecursively() } } diff --git a/app/src/test/java/org/terst/listen/fingerprint/MidrollDetectorTest.kt b/app/src/test/java/org/terst/listen/fingerprint/MidrollDetectorTest.kt deleted file mode 100644 index 70ead6c..0000000 --- a/app/src/test/java/org/terst/listen/fingerprint/MidrollDetectorTest.kt +++ /dev/null @@ -1,87 +0,0 @@ -package org.terst.listen.fingerprint - -import kotlin.random.Random -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Test - -class MidrollDetectorTest { - - private fun noise(seed: Int, n: Int) = Random(seed).let { r -> DoubleArray(n) { r.nextDouble(-50.0, 50.0) } } - - private fun embed(base: DoubleArray, segment: DoubleArray, at: Int): DoubleArray { - val out = base.copyOf() - segment.forEachIndexed { i, v -> out[at + i] = v } - return out - } - - private val ad = noise(seed = 7, n = 60) - - @Test - fun `a candidate that matches reaches the threshold and is reported detected`() { - val candidate = MidrollDetector.Candidate(id = "c1", referenceClip = ad, confirmedCount = 2) - val episode = embed(noise(1, 500), ad, at = 200) - - val result = MidrollDetector.check(listOf(candidate), "e3", episode) - - val survivor = result.candidates.single() - assertEquals(3, survivor.confirmedCount) - assertEquals("e3", survivor.lastConfirmedEpisodeId) - val detected = result.detected.single() - assertEquals("c1", detected.candidateId) - assertTrue("expected position near 200, was ${detected.positionInClip}", detected.positionInClip in 190..210) - assertEquals(ad.size, detected.lengthSamples) - } - - @Test - fun `a candidate below threshold after matching is tentative, not detected`() { - val candidate = MidrollDetector.Candidate(id = "c1", referenceClip = ad, confirmedCount = 1) - val episode = embed(noise(1, 500), ad, at = 200) - - val result = MidrollDetector.check(listOf(candidate), "e2", episode) - - assertEquals(2, result.candidates.single().confirmedCount) - assertTrue(result.detected.isEmpty()) - // Not confirmed, but MidrollUpdater still needs to know *where* it matched this episode - // -- to recognise a fresh discovery hit at the same spot as this same, still-tentative - // candidate rather than a genuinely different one. - val match = result.allMatches.single() - assertEquals("c1", match.candidateId) - assertTrue(match.positionInClip in 190..210) - } - - @Test - fun `a candidate that does not hold up is discarded, not weakened`() { - val candidate = MidrollDetector.Candidate(id = "c1", referenceClip = ad, confirmedCount = 2) - val unrelated = noise(99, 500) // the Darknet Diaries case, generalised to midroll - - val result = MidrollDetector.check(listOf(candidate), "e3", unrelated) - - assertTrue(result.candidates.isEmpty()) - assertTrue(result.detected.isEmpty()) - } - - @Test - fun `several candidates are each judged independently in the same run`() { - val good = MidrollDetector.Candidate(id = "good", referenceClip = ad, confirmedCount = 2) - val badAd = noise(seed = 8, n = 60) - val bad = MidrollDetector.Candidate(id = "bad", referenceClip = badAd, confirmedCount = 2) - val episode = embed(noise(1, 500), ad, at = 300) // only `ad` actually appears - - val result = MidrollDetector.check(listOf(good, bad), "e3", episode) - - assertEquals(listOf("good"), result.candidates.map { it.id }) - assertEquals(listOf("good"), result.detected.map { it.candidateId }) - } - - @Test - fun `reprocessing the same episode id is a no-op, not a double count`() { - val candidate = MidrollDetector.Candidate(id = "c1", referenceClip = ad, confirmedCount = 3, lastConfirmedEpisodeId = "e3") - val episode = embed(noise(1, 500), ad, at = 200) - - val result = MidrollDetector.check(listOf(candidate), "e3", episode) - - assertEquals(3, result.candidates.single().confirmedCount) - assertTrue("already-processed episodes are not re-detected", result.detected.isEmpty()) - } -} diff --git a/app/src/test/java/org/terst/listen/fingerprint/MidrollScannerTest.kt b/app/src/test/java/org/terst/listen/fingerprint/MidrollScannerTest.kt deleted file mode 100644 index 9a0b091..0000000 --- a/app/src/test/java/org/terst/listen/fingerprint/MidrollScannerTest.kt +++ /dev/null @@ -1,68 +0,0 @@ -package org.terst.listen.fingerprint - -import kotlin.random.Random -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Test - -/** - * Windows and recordings here are deliberately large (hundreds, not tens, of samples): a short - * window's null-distribution of "best correlation somewhere in a few hundred random offsets" has - * high enough variance to clear 0.6 by pure chance, which was measured empirically before sizing - * these fixtures. Real audio never has this problem -- these sizes exist only so the tests mean - * what they say. - */ -class MidrollScannerTest { - - private fun noise(seed: Int, n: Int) = Random(seed).let { r -> DoubleArray(n) { r.nextDouble(-50.0, 50.0) } } - - private fun embed(base: DoubleArray, segment: DoubleArray, at: Int): DoubleArray { - val out = base.copyOf() - segment.forEachIndexed { i, v -> out[at + i] = v } - return out - } - - private val window = 150 - private val step = 75 - private val minScore = 0.6 - - @Test - fun `finds a shared segment at different positions in each recording`() { - val ad = noise(seed = 99, n = 220) - val a = embed(noise(1, 3000), ad, at = 500) - val b = embed(noise(2, 3000), ad, at = 1800) - - val hits = MidrollScanner.findSharedSegments(a, b, window, step, minScore) - - assertEquals(1, hits.size) - val hit = hits.single() - assertTrue("expected positionInA near 500, was ${hit.positionInA}", hit.positionInA in 350..650) - assertTrue("expected positionInB near 1800, was ${hit.positionInB}", hit.positionInB in 1650..1950) - assertTrue("expected a strong score, was ${hit.score}", hit.score > 0.8) - } - - @Test - fun `two unrelated recordings share nothing`() { - val a = noise(1, 3000) - val b = noise(2, 3000) - - val hits = MidrollScanner.findSharedSegments(a, b, window, step, minScore) - - assertTrue(hits.isEmpty()) - } - - @Test - fun `two distinct shared segments are reported separately, not merged`() { - val first = noise(101, 220) - val second = noise(202, 220) - val a = embed(embed(noise(1, 3000), first, at = 200), second, at = 2000) - val b = embed(embed(noise(2, 3000), first, at = 1000), second, at = 2600) - - val hits = MidrollScanner.findSharedSegments(a, b, window, step, minScore) - - assertEquals(2, hits.size) - val diagonals = hits.map { it.positionInA - it.positionInB }.sorted() - // 200 - 1000 = -800, 2000 - 2600 = -600 -- distinct alignments, not one merged run. - assertTrue("diagonals should be well separated, was $diagonals", diagonals[1] - diagonals[0] > step) - } -} diff --git a/app/src/test/java/org/terst/listen/fingerprint/MidrollUpdaterTest.kt b/app/src/test/java/org/terst/listen/fingerprint/MidrollUpdaterTest.kt deleted file mode 100644 index ae9478e..0000000 --- a/app/src/test/java/org/terst/listen/fingerprint/MidrollUpdaterTest.kt +++ /dev/null @@ -1,272 +0,0 @@ -package org.terst.listen.fingerprint - -import kotlin.random.Random -import kotlinx.coroutines.test.runTest -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Test -import org.terst.listen.data.DownloadState -import org.terst.listen.data.EpisodeEntity -import org.terst.listen.data.FakeEpisodeDao -import org.terst.listen.data.FakeMidrollDao -import org.terst.listen.data.FakeSubscriptionDao -import org.terst.listen.data.SubscriptionEntity - -/** - * Windows here match [MidrollScannerTest]'s sizes (150/75/3000-sample recordings), for the same - * reason: a smaller scale has enough null-distribution variance that random noise clears the - * score threshold by chance, which was measured empirically before choosing these numbers. - */ -class MidrollUpdaterTest { - - private lateinit var episodes: FakeEpisodeDao - private lateinit var subscriptions: FakeSubscriptionDao - private lateinit var midrolls: FakeMidrollDao - private lateinit var clips: FakeFullClipSource - private lateinit var clipStore: FakeMidrollClipStore - private lateinit var updater: MidrollUpdater - - private val fixedNow = 555_000L - - @Before - fun setUp() { - episodes = FakeEpisodeDao() - subscriptions = FakeSubscriptionDao() - midrolls = FakeMidrollDao().apply { episodeRows = episodes.rows } - clips = FakeFullClipSource() - clipStore = FakeMidrollClipStore() - updater = MidrollUpdater( - subscriptions, - episodes, - midrolls, - clips, - clipStore, - // No decimation and a MidrollScannerTest-scale window: the coarse-to-fine split is - // MidrollUpdater's own concern to compose correctly, not something this test needs - // to exercise at real (8kHz-vs-1kHz) scale on top of everything else. - MidrollUpdater.Tuning(coarseFactor = 1, discoverySamples = 150, now = { fixedNow }), - ) - } - - private fun noise(seed: Int, n: Int) = Random(seed).let { r -> DoubleArray(n) { r.nextDouble(-50.0, 50.0) } } - - private fun embed(base: DoubleArray, segment: DoubleArray, at: Int): DoubleArray { - val out = base.copyOf() - segment.forEachIndexed { i, v -> out[at + i] = v } - return out - } - - private fun show(id: String = "show1") { - subscriptions.rows += SubscriptionEntity(id = id, feedUrl = "https://feed/$id", title = id, addedAtMs = 0) - } - - private fun downloadedEpisode(id: String, downloadedAtMs: Long, durationMs: Long = 600_000): EpisodeEntity { - val ep = EpisodeEntity( - id = id, subscriptionId = "show1", guid = id, title = "Episode $id", - mediaUrl = "https://cdn/$id.mp3", mediaType = "audio/mpeg", discoveredAtMs = 0, - localPath = "/fake/$id.mp3", durationMs = durationMs, - downloadState = DownloadState.DOWNLOADED, downloadedAtMs = downloadedAtMs, - ) - episodes.rows += ep - return ep - } - - private val ad = noise(seed = 7, n = 220) - - @Test - fun `nothing to do when no episode is unchecked`() = runTest { - show() - updater.processShow("show1") - assertTrue(midrolls.candidateRows.isEmpty()) - } - - @Test - fun `the production tuning defaults construct a working updater`() = runTest { - show() - val defaultUpdater = MidrollUpdater(subscriptions, episodes, midrolls, clips, clipStore) - defaultUpdater.processShow("show1") - assertTrue(midrolls.candidateRows.isEmpty()) - } - - @Test - fun `the first downloaded episode only becomes the pending basis`() = runTest { - show() - clips.full["e1"] = embed(noise(1, 3000), ad, at = 500) - downloadedEpisode("e1", downloadedAtMs = 1) - - updater.processShow("show1") - - assertTrue(midrolls.candidateRows.isEmpty()) - assertEquals("e1", subscriptions.rows.single().midrollPendingEpisodeId) - assertEquals(fixedNow, episodes.rows.single { it.id == "e1" }.midrollCheckedAtMs) - } - - @Test - fun `a second episode sharing a segment with the pending one seeds a tentative candidate`() = runTest { - show() - clips.full["e1"] = embed(noise(1, 3000), ad, at = 500) - clips.full["e2"] = embed(noise(2, 3000), ad, at = 1800) - downloadedEpisode("e1", downloadedAtMs = 1) - downloadedEpisode("e2", downloadedAtMs = 2) - - updater.processShow("show1") - updater.processShow("show1") - - val candidate = midrolls.candidateRows.single() - assertEquals(2, candidate.confirmedCount) - assertEquals("e2", candidate.lastConfirmedEpisodeId) - } - - @Test - fun `a third independent episode confirms the candidate and records a skip range`() = runTest { - show() - clips.full["e1"] = embed(noise(1, 3000), ad, at = 500) - clips.full["e2"] = embed(noise(2, 3000), ad, at = 1800) - clips.full["e3"] = embed(noise(3, 3000), ad, at = 1000) - downloadedEpisode("e1", downloadedAtMs = 1) - downloadedEpisode("e2", downloadedAtMs = 2) - downloadedEpisode("e3", downloadedAtMs = 3) - - updater.processShow("show1") - updater.processShow("show1") - updater.processShow("show1") - - val candidate = midrolls.candidateRows.single() - assertEquals(3, candidate.confirmedCount) - - val range = midrolls.rangeRows.single { it.episodeId == "e3" } - val expectedStartMs = 1000L * 1000 / clips.sampleRate - val toleranceMs = 300L * 1000 / clips.sampleRate - assertTrue( - "expected startMs near $expectedStartMs, was ${range.startMs}", - range.startMs in (expectedStartMs - toleranceMs)..(expectedStartMs + toleranceMs), - ) - assertTrue(range.endMs > range.startMs) - } - - @Test - fun `an episode lacking the confirmed segment discards it instead of weakening it`() = runTest { - show() - clips.full["e1"] = embed(noise(1, 3000), ad, at = 500) - clips.full["e2"] = embed(noise(2, 3000), ad, at = 1800) - clips.full["e3"] = noise(99, 3000) // the Darknet Diaries case, generalised to midroll - downloadedEpisode("e1", downloadedAtMs = 1) - downloadedEpisode("e2", downloadedAtMs = 2) - downloadedEpisode("e3", downloadedAtMs = 3) - - updater.processShow("show1") - updater.processShow("show1") - updater.processShow("show1") - - assertTrue("the false candidate must be discarded", midrolls.candidateRows.isEmpty()) - assertTrue(midrolls.rangeRows.isEmpty()) - } - - @Test - fun `an episode that fails to decode is marked checked so it is not retried forever`() = runTest { - show() - downloadedEpisode("e1", downloadedAtMs = 1) // no clip seeded -- FakeFullClipSource returns null - - updater.processShow("show1") - - assertEquals(fixedNow, episodes.rows.single().midrollCheckedAtMs) - } - - /** - * The decode a full-body scan needs is held entirely in memory, so an episode's length is - * also an allocation. An hour of 8kHz mono is 28.8 million doubles -- 230MB, on a phone with - * no largeHeap -- and before this the updater asked for it anyway: `decodeFull` threw - * OutOfMemoryError, `runCatching` swallowed it, and the episode was then marked checked - * forever, so no episode was ever scanned and nothing said why. - * - * Over budget now costs nothing and is left unchecked, so the slice-on-demand decode that - * lifts the limit picks these episodes up rather than finding them all written off. - */ - @Test - fun `an episode too long to hold in memory is never decoded`() = runTest { - show() - downloadedEpisode("marathon", downloadedAtMs = 1, durationMs = 3 * 60 * 60 * 1000L) - - updater.processShow("show1") - - assertEquals(emptyList<String>(), clips.decodeRequests) - } - - @Test - fun `an episode too long to hold in memory is left for later, not marked checked`() = runTest { - show() - downloadedEpisode("marathon", downloadedAtMs = 1, durationMs = 3 * 60 * 60 * 1000L) - - updater.processShow("show1") - - assertNull( - "an over-budget episode marked checked can never be scanned again", - episodes.rows.single { it.id == "marathon" }.midrollCheckedAtMs, - ) - } - - /** An over-budget episode must not become the pending basis either, or it blocks the show. */ - @Test - fun `an episode too long to hold in memory does not become the pending basis`() = runTest { - show() - downloadedEpisode("marathon", downloadedAtMs = 1, durationMs = 3 * 60 * 60 * 1000L) - - updater.processShow("show1") - - assertNull(subscriptions.rows.single().midrollPendingEpisodeId) - } - - /** A normal-length episode is unaffected -- the budget is a ceiling, not a new gate. */ - @Test - fun `an episode inside the budget is decoded as before`() = runTest { - show() - clips.full["e1"] = embed(noise(1, 3000), ad, at = 500) - downloadedEpisode("e1", downloadedAtMs = 1, durationMs = 600_000) - - updater.processShow("show1") - - assertEquals(listOf("e1"), clips.decodeRequests) - assertEquals(fixedNow, episodes.rows.single { it.id == "e1" }.midrollCheckedAtMs) - } - - private class FakeFullClipSource : AudioClipSource { - val full = mutableMapOf<String, DoubleArray>() - val sampleRate = 8_000 - - /** Which episodes were actually asked for, so "never attempted" can be asserted. */ - val decodeRequests = mutableListOf<String>() - - override suspend fun decodeHead(episode: EpisodeEntity): DecodedClip? = null - override suspend fun decodeTail(episode: EpisodeEntity): DecodedClip? = null - override suspend fun decodeFull(episode: EpisodeEntity): DecodedClip? { - decodeRequests += episode.id - return full[episode.id]?.let { DecodedClip(it, sampleRate, startMs = 0) } - } - } -} - -/** - * Midroll clips in memory rather than on disk. Keyed by the path it hands back, so a test can - * delete one behind the updater's back the way retention or a wipe would. - */ -class FakeMidrollClipStore : MidrollClipStore { - val clips = mutableMapOf<String, DoubleArray>() - - override suspend fun write(subscriptionId: String, candidateId: String, samples: DoubleArray): String { - val path = "$subscriptionId/$candidateId.pcm" - clips[path] = samples - return path - } - - override suspend fun read(path: String): DoubleArray? = clips[path] - - override suspend fun delete(path: String) { - clips -= path - } - - override suspend fun deleteAllFor(subscriptionId: String) { - clips.keys.filter { it.startsWith("$subscriptionId/") }.forEach { clips -= it } - } -} diff --git a/app/src/test/java/org/terst/listen/fingerprint/MonoResamplerTest.kt b/app/src/test/java/org/terst/listen/fingerprint/MonoResamplerTest.kt deleted file mode 100644 index 068585b..0000000 --- a/app/src/test/java/org/terst/listen/fingerprint/MonoResamplerTest.kt +++ /dev/null @@ -1,161 +0,0 @@ -package org.terst.listen.fingerprint - -import kotlin.random.Random -import org.junit.Assert.assertArrayEquals -import org.junit.Assert.assertEquals -import org.junit.Test - -/** - * The decode a full-episode scan needs, made to cost its output rather than its input. - * - * [MediaCodecClipSource] used to buffer the whole episode at its native rate -- a `ShortArray` - * of every interleaved sample, then a `DoubleArray` of every mono frame -- and only then - * resample to 8kHz. For a 13-minute stereo 44.1kHz episode that is 145MB plus 289MB live at - * once, on a phone with no largeHeap, to produce 6MB of output. It threw OutOfMemoryError, - * `runCatching` swallowed it, and midroll detection silently never ran on anything. - * - * This consumes the decoder's output chunk by chunk and keeps only the 8kHz mono result, so the - * peak allocation is the result. Being pure, it is also the half of that adapter that can - * actually be tested -- the rest is MediaCodec plumbing. - * - * The resampling is deliberately the same naive linear interpolation as before: measured at - * 0.9991 correlation against a proper ffmpeg/swresample decode of the same audio - * (`docs/detection-findings.md`), so it was never the weak link and this is not the change to - * start improving it in. - */ -class MonoResamplerTest { - - /** - * The batch formula the streaming one has to agree with, lifted from the implementation it - * replaces. Present as an oracle so the chunking is what gets tested -- the formula itself - * is pinned separately, by hand, in [matches hand-computed linear interpolation]. - */ - private fun batch(interleaved: ShortArray, channels: Int, inputRate: Int, targetRate: Int): DoubleArray { - val frames = interleaved.size / channels - val mono = DoubleArray(frames) { frame -> - var sum = 0.0 - for (c in 0 until channels) sum += interleaved[frame * channels + c] - sum / channels - } - if (inputRate == targetRate || mono.isEmpty()) return mono - - val ratio = targetRate.toDouble() / inputRate - val outLength = (mono.size * ratio).toInt() - return DoubleArray(outLength) { i -> - val sourcePosition = i / ratio - val index = sourcePosition.toInt().coerceIn(0, mono.size - 1) - val next = (index + 1).coerceAtMost(mono.size - 1) - val fraction = sourcePosition - index - mono[index] * (1 - fraction) + mono[next] * fraction - } - } - - private fun streamed( - interleaved: ShortArray, - channels: Int, - inputRate: Int, - targetRate: Int, - chunkFrames: Int, - ): DoubleArray { - val resampler = MonoResampler(inputRate = inputRate, channels = channels, targetRate = targetRate) - var offset = 0 - while (offset < interleaved.size) { - val count = minOf(chunkFrames * channels, interleaved.size - offset) - resampler.accept(interleaved, offset, count) - offset += count - } - return resampler.finish() - } - - @Test - fun `matches hand-computed linear interpolation`() { - // Mono, 4Hz in, 2Hz out: ratio 0.5, so output i reads source position 2i exactly and - // every fraction is zero -- every other sample, with no interpolation to obscure it. - val resampler = MonoResampler(inputRate = 4, channels = 1, targetRate = 2) - resampler.accept(shortArrayOf(10, 20, 30, 40), 0, 4) - - assertArrayEquals(doubleArrayOf(10.0, 30.0), resampler.finish(), 1e-9) - } - - @Test - fun `interpolates between neighbouring samples on a fractional position`() { - // 4Hz in, 3Hz out: ratio 0.75, so output 1 sits at source position 1.333... - val resampler = MonoResampler(inputRate = 4, channels = 1, targetRate = 3) - resampler.accept(shortArrayOf(0, 300, 600, 900), 0, 4) - - val out = resampler.finish() - assertEquals(3, out.size) - assertEquals(0.0, out[0], 1e-9) - assertEquals(300 * (1 - 1.0 / 3) + 600 * (1.0 / 3), out[1], 1e-9) - assertEquals(600 * (1 - 2.0 / 3) + 900 * (2.0 / 3), out[2], 1e-9) - } - - @Test - fun `averages channels down to mono`() { - val resampler = MonoResampler(inputRate = 8, channels = 2, targetRate = 8) - resampler.accept(shortArrayOf(10, 20, 100, 200), 0, 4) - - assertArrayEquals(doubleArrayOf(15.0, 150.0), resampler.finish(), 1e-9) - } - - /** - * The actual risk in streaming this: an output sample interpolates between two source - * frames, and those two can straddle a chunk boundary. A decoder hands over whatever buffer - * sizes it likes, so every split has to give the same answer as no split at all. - */ - @Test - fun `chunking the input does not change the output`() { - val random = Random(42) - val interleaved = ShortArray(20_000) { random.nextInt(-3000, 3000).toShort() } - val expected = batch(interleaved, channels = 2, inputRate = 44_100, targetRate = 8_000) - - for (chunkFrames in listOf(1, 2, 3, 7, 64, 999, 10_000)) { - assertArrayEquals( - "a $chunkFrames-frame chunking disagreed with the unchunked result", - expected, - streamed(interleaved, 2, 44_100, 8_000, chunkFrames), - 1e-9, - ) - } - } - - @Test - fun `chunking does not change the output when no resampling is needed`() { - val random = Random(7) - val interleaved = ShortArray(5_000) { random.nextInt(-3000, 3000).toShort() } - val expected = batch(interleaved, channels = 1, inputRate = 8_000, targetRate = 8_000) - - for (chunkFrames in listOf(1, 5, 333, 5_000)) { - assertArrayEquals(expected, streamed(interleaved, 1, 8_000, 8_000, chunkFrames), 1e-9) - } - } - - @Test - fun `upsampling agrees with the batch formula too`() { - val random = Random(9) - val interleaved = ShortArray(3_000) { random.nextInt(-3000, 3000).toShort() } - val expected = batch(interleaved, channels = 2, inputRate = 8_000, targetRate = 16_000) - - for (chunkFrames in listOf(1, 11, 1_500)) { - assertArrayEquals(expected, streamed(interleaved, 2, 8_000, 16_000, chunkFrames), 1e-9) - } - } - - @Test - fun `nothing accepted produces nothing`() { - assertEquals(0, MonoResampler(inputRate = 44_100, channels = 2, targetRate = 8_000).finish().size) - } - - /** - * The whole point, stated as a number: the result is sized by the output rate, so what the - * decoder was handed at its native rate is never all live at once. - */ - @Test - fun `the result is sized by the target rate, not the input rate`() { - val resampler = MonoResampler(inputRate = 44_100, channels = 2, targetRate = 8_000) - val oneSecondStereo = ShortArray(44_100 * 2) - resampler.accept(oneSecondStereo, 0, oneSecondStereo.size) - - assertEquals(8_000, resampler.finish().size) - } -} diff --git a/app/src/test/java/org/terst/listen/fingerprint/PcmCodecTest.kt b/app/src/test/java/org/terst/listen/fingerprint/PcmCodecTest.kt deleted file mode 100644 index 9267828..0000000 --- a/app/src/test/java/org/terst/listen/fingerprint/PcmCodecTest.kt +++ /dev/null @@ -1,37 +0,0 @@ -package org.terst.listen.fingerprint - -import org.junit.Assert.assertArrayEquals -import org.junit.Assert.assertEquals -import org.junit.Test - -class PcmCodecTest { - - @Test - fun `round-trips samples through 16-bit PCM bytes`() { - val samples = doubleArrayOf(0.0, 1000.0, -1000.0, 32767.0, -32768.0, 500.5) - val bytes = PcmCodec.encode(samples) - val decoded = PcmCodec.decode(bytes) - assertEquals(samples.size, decoded.size) - for (i in samples.indices) { - assertEquals(samples[i], decoded[i], 1.0) - } - } - - @Test - fun `clamps samples outside 16-bit range rather than overflowing`() { - val bytes = PcmCodec.encode(doubleArrayOf(100_000.0, -100_000.0)) - val decoded = PcmCodec.decode(bytes) - assertEquals(32767.0, decoded[0], 0.0) - assertEquals(-32768.0, decoded[1], 0.0) - } - - @Test - fun `an empty array round-trips to an empty array`() { - assertArrayEquals(DoubleArray(0), PcmCodec.decode(PcmCodec.encode(DoubleArray(0))), 0.0) - } - - @Test - fun `two bytes per sample`() { - assertEquals(6, PcmCodec.encode(doubleArrayOf(1.0, 2.0, 3.0)).size) - } -} |
