summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.coverage-baseline2
-rw-r--r--CLAUDE.md81
-rw-r--r--app/build.gradle.kts8
-rw-r--r--app/src/main/java/org/terst/listen/data/Daos.kt21
-rw-r--r--app/src/main/java/org/terst/listen/fingerprint/AudioCorrelation.kt181
-rw-r--r--app/src/main/java/org/terst/listen/fingerprint/Decimate.kt24
-rw-r--r--app/src/main/java/org/terst/listen/fingerprint/Fft.kt83
-rw-r--r--app/src/main/java/org/terst/listen/fingerprint/FingerprintUpdater.kt158
-rw-r--r--app/src/main/java/org/terst/listen/fingerprint/IntroOutroDetector.kt285
-rw-r--r--app/src/main/java/org/terst/listen/fingerprint/MediaCodecClipSource.kt207
-rw-r--r--app/src/main/java/org/terst/listen/fingerprint/MidrollClipStore.kt48
-rw-r--r--app/src/main/java/org/terst/listen/fingerprint/MidrollDetector.kt91
-rw-r--r--app/src/main/java/org/terst/listen/fingerprint/MidrollScanner.kt79
-rw-r--r--app/src/main/java/org/terst/listen/fingerprint/MidrollUpdater.kt300
-rw-r--r--app/src/main/java/org/terst/listen/fingerprint/MonoResampler.kt134
-rw-r--r--app/src/main/java/org/terst/listen/fingerprint/PcmCodec.kt41
-rw-r--r--app/src/main/java/org/terst/listen/fingerprint/SkipSummary.kt16
-rw-r--r--app/src/main/java/org/terst/listen/fingerprint/StoredClip.kt24
-rw-r--r--app/src/main/java/org/terst/listen/ui/ShowSettingsViewModel.kt8
-rw-r--r--app/src/test/java/org/terst/listen/ReachabilityTest.kt34
-rw-r--r--app/src/test/java/org/terst/listen/data/FakeDaos.kt17
-rw-r--r--app/src/test/java/org/terst/listen/data/StoredClipSizeTest.kt52
-rw-r--r--app/src/test/java/org/terst/listen/data/UnsubscribeTest.kt9
-rw-r--r--app/src/test/java/org/terst/listen/fingerprint/AudioCorrelationTest.kt197
-rw-r--r--app/src/test/java/org/terst/listen/fingerprint/DecimateTest.kt35
-rw-r--r--app/src/test/java/org/terst/listen/fingerprint/FingerprintUpdaterTest.kt131
-rw-r--r--app/src/test/java/org/terst/listen/fingerprint/IntroOutroDetectorScaleTest.kt171
-rw-r--r--app/src/test/java/org/terst/listen/fingerprint/IntroOutroDetectorTest.kt191
-rw-r--r--app/src/test/java/org/terst/listen/fingerprint/MidrollClipStoreTest.kt89
-rw-r--r--app/src/test/java/org/terst/listen/fingerprint/MidrollDetectorTest.kt87
-rw-r--r--app/src/test/java/org/terst/listen/fingerprint/MidrollScannerTest.kt68
-rw-r--r--app/src/test/java/org/terst/listen/fingerprint/MidrollUpdaterTest.kt272
-rw-r--r--app/src/test/java/org/terst/listen/fingerprint/MonoResamplerTest.kt161
-rw-r--r--app/src/test/java/org/terst/listen/fingerprint/PcmCodecTest.kt37
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
diff --git a/CLAUDE.md b/CLAUDE.md
index ee50c3d..b32083e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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)
- }
-}