summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-11-04 10:14:52 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-11-04 10:14:52 +0000
commit9678aa3e980d1e44defb3808812291968565f869 (patch)
tree41a2c735551253228bda3c6b6af269a340ee067d /app
parentba6a03b2bd22ba29506e16caff708383caec07ac (diff)
Delete on-device detection. Detection is the server's
Peter's call: server first. The audio-fingerprint subsystem ran on the phone until detection moved to the server, and then kept being shipped: FingerprintWorker and MidrollWorker went at the time and the twelve classes they drove stayed, unreachable, in every APK since. ReachabilityTest found it on 2026-11-03 once it was widened to classes, and it has been sitting in that test's allowlist with its reasoning since. Gone: IntroOutroDetector, AudioCorrelation, MidrollScanner, MidrollDetector, MidrollUpdater, FingerprintUpdater, MediaCodecClipSource, Decimate, Fft, MonoResampler, PcmCodec, StoredClip, and the 67 unit tests over them. 3,092 lines. **Recoverable by name, not by archaeology.** The tag `on-device-detection` marks the last commit that has it, pushed to the local remote along with everything else from this session, and the tag message says how to bring it back. The work cost weeks and the approach may be wanted again. What stayed, because none of it is detection: SkipSummary words the readout, BreakTrack and BreakPopup draw and explain the timeline's marks, and MidrollClipStore shrank to deleteAllFor alone -- its write and read halves had one caller each and both were the deleted updater, but a phone that ran the old version still has clip files on disk and unsubscribing is the only thing that clears them. Its test now writes the old layout by hand, because that layout is a compatibility concern now rather than this class's own choice. IntroOutroDetector.CONFIRM_THRESHOLD and MidrollDetector.CONFIRM_THRESHOLD moved to SkipSummary, the only thing left that reads them. Both were three and the server's DefaultConfirmThreshold is also three, checked against each other before the move. **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. Four MidrollDao methods went because they were the discovery half (upsertCandidate, deleteCandidate, getUncheckedDownloaded, setMidrollCheckedAtMs) and the reachability check named them; a DAO method is not schema, so there is no migration here and nothing for a real library to go wrong over. Two things found while doing it, neither caused by it, both written up in CLAUDE.md: - **The per-show "Detected skips" readout can never say anything.** It reads candidate and confirmation rows, and nothing has written one since detection moved -- the server writes per-episode ranges and offsets instead. Verified on the emulator, not inferred: a fresh install with a live subscription shows "Nothing found yet / Nothing found yet / None found yet" while the server is finding and publishing breaks for that very show. It has been lying since the migration; deleting the detector only makes it undeniable. - **RetentionSweeper's pending-episode guard protects nothing**, for the same reason: nothing sets a pending basis any more, so both id sets are always empty. The APK barely moves -- 110KB of dex, 72.61MB to 72.50MB -- because it is dominated by the embedded Python. This was never going to be a size win and is not being sold as one; it is 3,092 fewer lines to read, search and mislead. 1004 tests pass, detekt 0, lint clean. Coverage 95.35% -> 94.58%, which is the documented COVERAGE_EXCEPTION case: deleting dead code that happened to be well covered. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
Diffstat (limited to 'app')
-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
32 files changed, 82 insertions, 3177 deletions
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)
- }
-}