summaryrefslogtreecommitdiff
path: root/app/src
diff options
context:
space:
mode:
Diffstat (limited to 'app/src')
-rw-r--r--app/src/androidTest/java/org/terst/listen/data/MigrationTest.kt47
-rw-r--r--app/src/main/java/org/terst/listen/data/AppDatabase.kt32
-rw-r--r--app/src/main/java/org/terst/listen/data/Daos.kt23
-rw-r--r--app/src/main/java/org/terst/listen/data/Entities.kt105
-rw-r--r--app/src/main/java/org/terst/listen/data/Repositories.kt12
-rw-r--r--app/src/main/java/org/terst/listen/download/EpisodeDownloader.kt12
-rw-r--r--app/src/main/java/org/terst/listen/fingerprint/AudioCorrelation.kt130
-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.kt112
-rw-r--r--app/src/main/java/org/terst/listen/fingerprint/IntroOutroDetector.kt176
-rw-r--r--app/src/main/java/org/terst/listen/fingerprint/MediaCodecClipSource.kt179
-rw-r--r--app/src/main/java/org/terst/listen/fingerprint/PcmCodec.kt41
-rw-r--r--app/src/main/java/org/terst/listen/playback/DetectedSkip.kt15
-rw-r--r--app/src/main/java/org/terst/listen/playback/PlaybackAdvancer.kt5
-rw-r--r--app/src/main/java/org/terst/listen/playback/PlaybackService.kt3
-rw-r--r--app/src/main/java/org/terst/listen/playback/PlayheadPolicy.kt13
-rw-r--r--app/src/main/java/org/terst/listen/work/Workers.kt39
-rw-r--r--app/src/test/java/org/terst/listen/data/FakeDaos.kt30
-rw-r--r--app/src/test/java/org/terst/listen/data/ShowFingerprintEntityTest.kt57
-rw-r--r--app/src/test/java/org/terst/listen/fingerprint/AudioCorrelationTest.kt160
-rw-r--r--app/src/test/java/org/terst/listen/fingerprint/FingerprintUpdaterTest.kt129
-rw-r--r--app/src/test/java/org/terst/listen/fingerprint/IntroOutroDetectorTest.kt191
-rw-r--r--app/src/test/java/org/terst/listen/fingerprint/PcmCodecTest.kt37
-rw-r--r--app/src/test/java/org/terst/listen/playback/PlayheadPolicyTest.kt2
24 files changed, 1617 insertions, 16 deletions
diff --git a/app/src/androidTest/java/org/terst/listen/data/MigrationTest.kt b/app/src/androidTest/java/org/terst/listen/data/MigrationTest.kt
index cf691e0..1f4db52 100644
--- a/app/src/androidTest/java/org/terst/listen/data/MigrationTest.kt
+++ b/app/src/androidTest/java/org/terst/listen/data/MigrationTest.kt
@@ -222,4 +222,51 @@ class MigrationTest {
assertEquals("the show itself must survive the flip", "A Show", cursor.getString(1))
}
}
+
+ /**
+ * Tier 3: the new episode columns default to null (nothing detected yet for existing
+ * episodes), and the new table is actually usable -- not just present.
+ */
+ @Test
+ fun migrates6To7AddingDetectedSkipsAndFingerprintTable() {
+ helper.createDatabase(name, 6).use { db ->
+ db.execSQL(
+ """INSERT INTO subscriptions
+ (id, feedUrl, kind, title, addedAtMs, skipIntroMs, skipOutroMs, dripEnabled,
+ dripIntervalMs, dripCount, dripOldestFirst, skipAdChapters)
+ VALUES ('s1', 'https://example.com/feed.xml', 'PODCAST', 'A Show', 1000, 0, 0,
+ 0, 86400000, 1, 1, 1)"""
+ )
+ db.execSQL(
+ """INSERT INTO episodes
+ (id, subscriptionId, guid, title, mediaUrl, mediaType, discoveredAtMs,
+ positionMs, downloadState)
+ VALUES ('e1', 's1', 'g1', 'One', 'https://cdn/1.mp3', 'audio/mpeg', 1000,
+ 424242, 'DOWNLOADED')"""
+ )
+ }
+
+ val db = helper.runMigrationsAndValidate(name, 7, true, AppDatabase.MIGRATION_6_7)
+
+ db.query("SELECT detectedIntroMs, detectedOutroMs, positionMs FROM episodes WHERE id = 'e1'")
+ .use { cursor ->
+ cursor.moveToFirst()
+ assertEquals("nothing detected yet for an existing episode", true, cursor.isNull(0))
+ assertEquals(true, cursor.isNull(1))
+ assertEquals("the crown jewel must survive", 424242L, cursor.getLong(2))
+ }
+
+ db.execSQL(
+ """INSERT INTO show_fingerprints
+ (subscriptionId, kind, referenceClipPcm, pendingClipPcm, pendingEpisodeId,
+ sampleRate, confirmedCount, lastConfirmedEpisodeId, updatedAtMs)
+ VALUES ('s1', 'INTRO', NULL, NULL, NULL, 8000, 2, 'e1', 5000)"""
+ )
+ db.query("SELECT confirmedCount FROM show_fingerprints WHERE subscriptionId = 's1' AND kind = 'INTRO'")
+ .use { cursor ->
+ assertEquals(1, cursor.count)
+ cursor.moveToFirst()
+ assertEquals(2, cursor.getInt(0))
+ }
+ }
}
diff --git a/app/src/main/java/org/terst/listen/data/AppDatabase.kt b/app/src/main/java/org/terst/listen/data/AppDatabase.kt
index 4ff9f50..cdfbf02 100644
--- a/app/src/main/java/org/terst/listen/data/AppDatabase.kt
+++ b/app/src/main/java/org/terst/listen/data/AppDatabase.kt
@@ -16,6 +16,8 @@ class Converters {
@TypeConverter fun fromDownloadState(value: DownloadState) = value.name
@TypeConverter fun toRuleAction(value: String) = RuleAction.valueOf(value)
@TypeConverter fun fromRuleAction(value: RuleAction) = value.name
+ @TypeConverter fun toFingerprintKind(value: String) = FingerprintKind.valueOf(value)
+ @TypeConverter fun fromFingerprintKind(value: FingerprintKind) = value.name
}
@Database(
@@ -24,8 +26,9 @@ class Converters {
EpisodeEntity::class,
QueueEntity::class,
RuleEntity::class,
+ ShowFingerprintEntity::class,
],
- version = 6,
+ version = 7,
exportSchema = true,
)
@TypeConverters(Converters::class)
@@ -34,6 +37,7 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun episodes(): EpisodeDao
abstract fun queue(): QueueDao
abstract fun rules(): RuleDao
+ abstract fun fingerprints(): ShowFingerprintDao
companion object {
@Volatile private var instance: AppDatabase? = null
@@ -159,6 +163,31 @@ abstract class AppDatabase : RoomDatabase() {
}
}
+ /**
+ * Tier 3 of the skip feature: per-episode offsets computed from the show's own audio,
+ * and the fingerprint state each show's detection is tracking toward.
+ */
+ val MIGRATION_6_7 = object : Migration(6, 7) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL("ALTER TABLE `episodes` ADD COLUMN `detectedIntroMs` INTEGER")
+ db.execSQL("ALTER TABLE `episodes` ADD COLUMN `detectedOutroMs` INTEGER")
+ db.execSQL(
+ """CREATE TABLE IF NOT EXISTS `show_fingerprints` (
+ `subscriptionId` TEXT NOT NULL, `kind` TEXT NOT NULL,
+ `referenceClipPcm` BLOB, `pendingClipPcm` BLOB, `pendingEpisodeId` TEXT,
+ `sampleRate` INTEGER NOT NULL, `confirmedCount` INTEGER NOT NULL,
+ `lastConfirmedEpisodeId` TEXT, `updatedAtMs` INTEGER NOT NULL,
+ PRIMARY KEY(`subscriptionId`, `kind`),
+ FOREIGN KEY(`subscriptionId`) REFERENCES `subscriptions`(`id`)
+ ON DELETE CASCADE)"""
+ )
+ db.execSQL(
+ "CREATE INDEX IF NOT EXISTS `index_show_fingerprints_subscriptionId` " +
+ "ON `show_fingerprints` (`subscriptionId`)"
+ )
+ }
+ }
+
fun get(context: Context): AppDatabase = instance ?: synchronized(this) {
instance ?: build(context.applicationContext).also { instance = it }
}
@@ -169,6 +198,7 @@ abstract class AppDatabase : RoomDatabase() {
// deliberate addition, and the list is the record of them.
.addMigrations(
MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6,
+ MIGRATION_6_7,
)
.addCallback(SEED_DEFAULT_FILTERS)
// No fallbackToDestructiveMigration. A listener's playback positions and play
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 8b7b322..7bc54fc 100644
--- a/app/src/main/java/org/terst/listen/data/Daos.kt
+++ b/app/src/main/java/org/terst/listen/data/Daos.kt
@@ -286,6 +286,29 @@ interface RuleDao {
}
@Dao
+interface ShowFingerprintDao {
+ @Query("SELECT * FROM show_fingerprints WHERE subscriptionId = :subscriptionId AND kind = :kind")
+ suspend fun get(subscriptionId: String, kind: FingerprintKind): ShowFingerprintEntity?
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun upsert(fingerprint: ShowFingerprintEntity)
+
+ /**
+ * A per-episode offset computed from the show's confirmed fingerprint.
+ *
+ * Lives here rather than on [EpisodeDao], which the column's own table might suggest:
+ * intro and outro are set independently -- processing one end must not clobber whatever the
+ * other end already found for this same episode -- and this is Tier 3's own concern, not
+ * one more thing for the DAO that already does everything else an episode needs.
+ */
+ @Query("UPDATE episodes SET detectedIntroMs = :ms WHERE id = :id")
+ suspend fun setDetectedIntroMs(id: String, ms: Long)
+
+ @Query("UPDATE episodes SET detectedOutroMs = :ms WHERE id = :id")
+ suspend fun setDetectedOutroMs(id: String, ms: Long)
+}
+
+@Dao
interface QueueDao {
@Query(
"""SELECT e.* FROM episodes e
diff --git a/app/src/main/java/org/terst/listen/data/Entities.kt b/app/src/main/java/org/terst/listen/data/Entities.kt
index 547a4ad..014e3a0 100644
--- a/app/src/main/java/org/terst/listen/data/Entities.kt
+++ b/app/src/main/java/org/terst/listen/data/Entities.kt
@@ -167,6 +167,18 @@ data class EpisodeEntity(
* than whatever an earlier version managed to understand.
*/
val chaptersJson: String? = null,
+
+ /**
+ * Tier 3 of the skip feature: per-episode offsets computed by matching this episode's audio
+ * against the show's confirmed fingerprint (see [ShowFingerprintEntity]), rather than typed
+ * in by hand. Null until [ShowFingerprintEntity.confirmedCount] reaches the threshold for
+ * this show. Already in the same shape as [SubscriptionEntity.skipIntroMs]/`skipOutroMs`
+ * (duration from the start / duration from the end) so the two tiers can be resolved with
+ * a plain `detectedXMs ?: show.skipXMs` at the point playback actually uses them --
+ * `PlaybackProgress` itself has no idea a detector exists.
+ */
+ val detectedIntroMs: Long? = null,
+ val detectedOutroMs: Long? = null,
)
/** What an auto-queue rule does to an episode that matches it. */
@@ -207,6 +219,99 @@ data class RuleEntity(
val speed: Float? = null,
)
+/** Which end of an episode a fingerprint tracks. */
+enum class FingerprintKind { INTRO, OUTRO }
+
+/**
+ * A show's progress toward a trusted, automatically-detected intro or outro.
+ *
+ * One row per show per [kind] (the composite primary key), rather than a table of per-episode
+ * candidates: the whole point of [IntroOutroDetector][org.terst.listen.fingerprint.IntroOutroDetector]
+ * is that only the current best candidate and how many episodes have agreed on it need to
+ * survive between runs.
+ *
+ * Cascades with its show, same as episodes and the queue: a fingerprint for a show that no
+ * longer exists is not useful data, it is a dangling BLOB.
+ */
+@Entity(
+ tableName = "show_fingerprints",
+ primaryKeys = ["subscriptionId", "kind"],
+ foreignKeys = [
+ ForeignKey(
+ entity = SubscriptionEntity::class,
+ parentColumns = ["id"],
+ childColumns = ["subscriptionId"],
+ onDelete = ForeignKey.CASCADE,
+ ),
+ ],
+ indices = [Index(value = ["subscriptionId"])],
+)
+data class ShowFingerprintEntity(
+ val subscriptionId: String,
+ val kind: FingerprintKind,
+ /**
+ * The matched clip itself -- raw PCM samples, not a whole episode's audio. Cheap: a few
+ * hundred KB at most for the low sample rate and short duration this only ever needs once
+ * a candidate exists. Null until [confirmedCount] is at least 2.
+ */
+ val referenceClipPcm: ByteArray?,
+ /**
+ * The most recently processed episode's *whole* head/tail clip, kept only while there is no
+ * [referenceClipPcm] yet -- the next episode needs something to compare against to find a
+ * first candidate. Null again as soon as one is found; unlike the reference clip this is
+ * genuinely temporary and, unlike it, sized like a whole decoded clip rather than a short
+ * matched segment.
+ */
+ val pendingClipPcm: ByteArray?,
+ val pendingEpisodeId: String?,
+ val sampleRate: Int,
+ /**
+ * How many independent episodes have agreed on [referenceClipPcm]. Below the detector's
+ * confirmation threshold this is a candidate nobody should act on yet -- see the Darknet
+ * Diaries case in [org.terst.listen.fingerprint.IntroOutroDetector]'s own documentation for
+ * why that threshold exists at all.
+ */
+ val confirmedCount: Int,
+ /** So the same episode is never counted toward [confirmedCount] twice. */
+ val lastConfirmedEpisodeId: String?,
+ val updatedAtMs: Long,
+) {
+ // A data class with ByteArray properties needs equals/hashCode written by hand -- the
+ // generated ones compare array references, not contents, which silently breaks anything
+ // that relies on structural equality (including this class's own unit tests).
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is ShowFingerprintEntity) return false
+ return subscriptionId == other.subscriptionId &&
+ kind == other.kind &&
+ referenceClipPcm.contentEqualsOrBothNull(other.referenceClipPcm) &&
+ pendingClipPcm.contentEqualsOrBothNull(other.pendingClipPcm) &&
+ pendingEpisodeId == other.pendingEpisodeId &&
+ sampleRate == other.sampleRate &&
+ confirmedCount == other.confirmedCount &&
+ lastConfirmedEpisodeId == other.lastConfirmedEpisodeId &&
+ updatedAtMs == other.updatedAtMs
+ }
+
+ override fun hashCode(): Int {
+ var result = subscriptionId.hashCode()
+ result = 31 * result + kind.hashCode()
+ result = 31 * result + (referenceClipPcm?.contentHashCode() ?: 0)
+ result = 31 * result + (pendingClipPcm?.contentHashCode() ?: 0)
+ result = 31 * result + (pendingEpisodeId?.hashCode() ?: 0)
+ result = 31 * result + sampleRate
+ result = 31 * result + confirmedCount
+ result = 31 * result + (lastConfirmedEpisodeId?.hashCode() ?: 0)
+ result = 31 * result + updatedAtMs.hashCode()
+ return result
+ }
+
+ private companion object {
+ fun ByteArray?.contentEqualsOrBothNull(other: ByteArray?): Boolean =
+ if (this == null || other == null) this == other else contentEquals(other)
+ }
+}
+
/**
* One entry in the single Up Next queue.
*
diff --git a/app/src/main/java/org/terst/listen/data/Repositories.kt b/app/src/main/java/org/terst/listen/data/Repositories.kt
index cb017ac..ddcf820 100644
--- a/app/src/main/java/org/terst/listen/data/Repositories.kt
+++ b/app/src/main/java/org/terst/listen/data/Repositories.kt
@@ -50,6 +50,7 @@ class AppGraph(private val context: Context) {
val episodes: EpisodeDao get() = db.episodes()
val queueDao: QueueDao get() = db.queue()
val ruleDao: RuleDao get() = db.rules()
+ val fingerprints: ShowFingerprintDao get() = db.fingerprints()
val libraryDir: File by lazy {
File(context.filesDir, "audio").also { it.mkdirs() }
@@ -86,7 +87,16 @@ class AppGraph(private val context: Context) {
}
val downloader: EpisodeDownloader by lazy {
- EpisodeDownloader(client, episodes, libraryDir, ChaquopyAudioSource(context))
+ EpisodeDownloader(
+ client,
+ episodes,
+ libraryDir,
+ ChaquopyAudioSource(context),
+ // Fingerprinting needs the file on disk to decode, so it can only start once a
+ // download actually lands -- one job per episode rather than a periodic sweep,
+ // since the file is right there and there is nothing to gain by waiting.
+ onDownloaded = { episode -> Scheduling.fingerprintNow(context, episode.id) },
+ )
}
val retention: RetentionPolicy by lazy {
diff --git a/app/src/main/java/org/terst/listen/download/EpisodeDownloader.kt b/app/src/main/java/org/terst/listen/download/EpisodeDownloader.kt
index 831708f..ed8bf70 100644
--- a/app/src/main/java/org/terst/listen/download/EpisodeDownloader.kt
+++ b/app/src/main/java/org/terst/listen/download/EpisodeDownloader.kt
@@ -25,6 +25,13 @@ class EpisodeDownloader(
private val episodes: EpisodeDao,
private val libraryDir: File,
private val extractor: ExtractedAudioSource? = null,
+ /**
+ * Fired once a download actually lands on disk -- the trigger for Tier 3 fingerprint
+ * matching, which needs the file present to decode. Every download route ends up here
+ * regardless of whether it was a manual tap or a queued/rule-triggered batch, so this is
+ * the one place that guarantees every newly downloaded episode gets checked.
+ */
+ private val onDownloaded: suspend (EpisodeEntity) -> Unit = {},
private val now: () -> Long = System::currentTimeMillis,
) {
@@ -44,7 +51,10 @@ class EpisodeDownloader(
}
result.fold(
- onSuccess = { episodes.updateDownload(episode.id, DownloadState.DOWNLOADED, it.absolutePath, now()) },
+ onSuccess = {
+ episodes.updateDownload(episode.id, DownloadState.DOWNLOADED, it.absolutePath, now())
+ onDownloaded(episode.copy(downloadState = DownloadState.DOWNLOADED, localPath = it.absolutePath))
+ },
// localPath is cleared: a FAILED row pointing at a path invites something downstream
// to try playing a file that is not there.
onFailure = { episodes.updateDownload(episode.id, DownloadState.FAILED, null, null) },
diff --git a/app/src/main/java/org/terst/listen/fingerprint/AudioCorrelation.kt b/app/src/main/java/org/terst/listen/fingerprint/AudioCorrelation.kt
new file mode 100644
index 0000000..0b477a1
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/fingerprint/AudioCorrelation.kt
@@ -0,0 +1,130 @@
+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
+
+ /**
+ * 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? {
+ 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: DoubleArray): DoubleArray? {
+ val win = query.size
+ if (win == 0 || win > target.size) 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 localSum = rollingSum(target, win)
+ val localSumSq = rollingSum(DoubleArray(target.size) { target[it] * target[it] }, win)
+
+ val scores = DoubleArray(dot.size)
+ for (i in dot.indices) {
+ val mean = localSum[i] / win
+ val variance = max(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.size - kernel.size
+ * + 1`, computed via zero-padded FFT rather than the O(n*m) direct sum.
+ */
+ private fun validConvolution(target: DoubleArray, kernel: DoubleArray): DoubleArray {
+ val fullLength = target.size + kernel.size - 1
+ val n = Fft.nextPowerOfTwo(fullLength)
+
+ val targetRe = target.copyOf(n)
+ val targetIm = DoubleArray(n)
+ val kernelRe = kernel.copyOf(n)
+ val kernelIm = DoubleArray(n)
+
+ Fft.transform(targetRe, targetIm, inverse = false)
+ Fft.transform(kernelRe, kernelIm, inverse = false)
+
+ val productRe = DoubleArray(n)
+ val productIm = DoubleArray(n)
+ for (i in 0 until n) {
+ productRe[i] = targetRe[i] * kernelRe[i] - targetIm[i] * kernelIm[i]
+ productIm[i] = targetRe[i] * kernelIm[i] + targetIm[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.size - kernel.size + 1
+ return DoubleArray(validLength) { productRe[it + kernel.size - 1] }
+ }
+}
diff --git a/app/src/main/java/org/terst/listen/fingerprint/Fft.kt b/app/src/main/java/org/terst/listen/fingerprint/Fft.kt
new file mode 100644
index 0000000..b2feff8
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/fingerprint/Fft.kt
@@ -0,0 +1,83 @@
+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
new file mode 100644
index 0000000..6a71d83
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/fingerprint/FingerprintUpdater.kt
@@ -0,0 +1,112 @@
+package org.terst.listen.fingerprint
+
+import org.terst.listen.data.EpisodeEntity
+import org.terst.listen.data.FingerprintKind
+import org.terst.listen.data.ShowFingerprintDao
+import org.terst.listen.data.ShowFingerprintEntity
+
+/** 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?
+}
+
+/**
+ * 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 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)
+ 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.durationMs ?: return
+ fingerprints.setDetectedOutroMs(episode.id, (durationMs - matchedStartMs).coerceAtLeast(0))
+ }
+ }
+ }
+
+ private fun stateOf(stored: ShowFingerprintEntity?): FingerprintState {
+ if (stored == null) return FingerprintState()
+ return FingerprintState(
+ referenceClip = stored.referenceClipPcm?.let(PcmCodec::decode),
+ pendingClip = stored.pendingClipPcm?.let(PcmCodec::decode),
+ pendingEpisodeId = stored.pendingEpisodeId,
+ confirmedCount = stored.confirmedCount,
+ lastConfirmedEpisodeId = stored.lastConfirmedEpisodeId,
+ )
+ }
+
+ private fun entityOf(
+ state: FingerprintState,
+ subscriptionId: String,
+ kind: FingerprintKind,
+ sampleRate: Int,
+ ) = ShowFingerprintEntity(
+ subscriptionId = subscriptionId,
+ kind = kind,
+ referenceClipPcm = state.referenceClip?.let(PcmCodec::encode),
+ pendingClipPcm = state.pendingClip?.let(PcmCodec::encode),
+ pendingEpisodeId = state.pendingEpisodeId,
+ sampleRate = sampleRate,
+ confirmedCount = state.confirmedCount,
+ lastConfirmedEpisodeId = state.lastConfirmedEpisodeId,
+ updatedAtMs = now(),
+ )
+
+ private companion object {
+ const val DEFAULT_REFERENCE_SECONDS = 8
+ const val DEFAULT_SAMPLE_RATE = 8_000
+ 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
new file mode 100644
index 0000000..3a446d7
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/fingerprint/IntroOutroDetector.kt
@@ -0,0 +1,176 @@
+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 {
+
+ private const val CONFIRM_THRESHOLD = 3
+ private const val MIN_SCORE = 0.6
+ private const val CANDIDATE_STEP_DIVISOR = 4
+
+ fun check(
+ state: FingerprintState,
+ episodeId: String,
+ clip: DoubleArray,
+ referenceLengthSamples: Int,
+ confirmThreshold: Int = CONFIRM_THRESHOLD,
+ ): FingerprintUpdate {
+ if (episodeId == state.lastConfirmedEpisodeId || episodeId == state.pendingEpisodeId) {
+ return toUpdate(state, confirmThreshold)
+ }
+
+ val reference = state.referenceClip
+ if (reference == null) {
+ return checkAgainstPending(state, episodeId, clip, referenceLengthSamples)
+ }
+ return checkAgainstReference(state, episodeId, clip, reference, confirmThreshold)
+ }
+
+ private fun checkAgainstPending(
+ state: FingerprintState,
+ episodeId: String,
+ clip: DoubleArray,
+ referenceLengthSamples: Int,
+ ): FingerprintUpdate {
+ val pending = state.pendingClip
+ if (pending == null) {
+ return FingerprintUpdate.Waiting(
+ FingerprintState(pendingClip = clip, pendingEpisodeId = episodeId)
+ )
+ }
+
+ val candidate = findCandidate(pending, clip, referenceLengthSamples)
+ 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)
+ }
+ }
+
+ /**
+ * Slides a [referenceLengthSamples]-long window across [a] a quarter-window at a time,
+ * keeping whichever position matches best somewhere in [b] -- or null if nothing matches
+ * strongly enough to trust.
+ *
+ * A step equal to the full window would almost always straddle a real embedded segment
+ * rather than land on it exactly (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]). A quarter-window step costs more candidate checks
+ * but actually lands on the real segment.
+ */
+ private fun findCandidate(a: DoubleArray, b: DoubleArray, referenceLengthSamples: Int): DoubleArray? {
+ val win = referenceLengthSamples.coerceAtMost(a.size).coerceAtMost(b.size)
+ if (win <= 0) return null
+ val step = (win / CANDIDATE_STEP_DIVISOR).coerceAtLeast(1)
+
+ var bestScore = MIN_SCORE
+ var bestWindow: DoubleArray? = null
+ var start = 0
+ while (start + win <= a.size) {
+ val window = a.copyOfRange(start, start + win)
+ val match = AudioCorrelation.bestMatch(window, b)
+ if (match != null && match.score > bestScore) {
+ bestScore = match.score
+ bestWindow = window
+ }
+ start += step
+ }
+ 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
new file mode 100644
index 0000000..be140f9
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/fingerprint/MediaCodecClipSource.kt
@@ -0,0 +1,179 @@
+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
+
+/**
+ * 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)
+ }
+
+ override suspend fun decodeTail(episode: EpisodeEntity): DecodedClip? {
+ val path = episode.localPath ?: return null
+ val totalMs = episode.durationMs ?: return null
+ val startMs = (totalMs - CLIP_SECONDS * MS_PER_SECOND).coerceAtLeast(0)
+ return decode(path, startMs)
+ }
+
+ private suspend fun decode(path: String, startMs: Long): DecodedClip? =
+ withContext(Dispatchers.Default) {
+ runCatching { decodeBlocking(path, startMs) }.getOrNull()
+ }
+
+ private fun decodeBlocking(path: String, startMs: Long): 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 raw = decodeToPcm(extractor, format, inputRate, channels)
+ val mono = downmix(raw, channels)
+ val resampled = resample(mono, inputRate, TARGET_SAMPLE_RATE)
+ return DecodedClip(resampled, 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")
+ }
+
+ /** Interleaved PCM16 samples for up to [CLIP_SECONDS] of decoded audio. */
+ private fun decodeToPcm(extractor: MediaExtractor, format: MediaFormat, inputRate: Int, channels: Int): ShortArray {
+ 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()
+
+ // Sized to the clip length up front rather than grown: a boxed collection for
+ // several million samples would be real, avoidable garbage on a phone.
+ val target = ShortArray((CLIP_SECONDS.toLong() * inputRate * channels).toInt())
+ var written = 0
+ var inputDone = false
+ var outputDone = false
+ val info = MediaCodec.BufferInfo()
+
+ while (!outputDone && written < target.size) {
+ if (!inputDone) {
+ inputDone = feedInput(extractor, codec)
+ }
+ written = drainOutput(codec, info, target, written) { outputDone = true }
+ }
+ return if (written == target.size) target else target.copyOf(written)
+ } 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
+ }
+ }
+
+ private fun drainOutput(
+ codec: MediaCodec,
+ info: MediaCodec.BufferInfo,
+ target: ShortArray,
+ writtenSoFar: Int,
+ onEndOfStream: () -> Unit,
+ ): Int {
+ val outputIndex = codec.dequeueOutputBuffer(info, TIMEOUT_US)
+ if (outputIndex < 0) return writtenSoFar
+
+ var written = writtenSoFar
+ if (info.size > 0) {
+ val output = codec.getOutputBuffer(outputIndex)
+ if (output != null) {
+ output.position(info.offset)
+ output.limit(info.offset + info.size)
+ val shorts = output.order(ByteOrder.LITTLE_ENDIAN).asShortBuffer()
+ while (shorts.hasRemaining() && written < target.size) {
+ target[written] = shorts.get()
+ written++
+ }
+ }
+ }
+ codec.releaseOutputBuffer(outputIndex, false)
+ if (info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) onEndOfStream()
+ return written
+ }
+
+ private fun downmix(interleaved: ShortArray, channels: Int): DoubleArray {
+ if (channels <= 1) return DoubleArray(interleaved.size) { interleaved[it].toDouble() }
+ val frames = interleaved.size / channels
+ return DoubleArray(frames) { frame ->
+ var sum = 0.0
+ for (c in 0 until channels) sum += interleaved[frame * channels + c]
+ sum / channels
+ }
+ }
+
+ /**
+ * Linear-interpolation resample, not a proper anti-aliasing filter: matching only needs
+ * coarse spectral shape, not audiophile fidelity, and every source feed converges on the
+ * same [TARGET_SAMPLE_RATE] regardless of what it was encoded at.
+ */
+ private fun resample(mono: DoubleArray, inputRate: Int, targetRate: Int): DoubleArray {
+ 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 companion object {
+ const val CLIP_SECONDS = 240
+ const val TARGET_SAMPLE_RATE = 8_000
+ const val TIMEOUT_US = 10_000L
+ 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/PcmCodec.kt b/app/src/main/java/org/terst/listen/fingerprint/PcmCodec.kt
new file mode 100644
index 0000000..2865442
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/fingerprint/PcmCodec.kt
@@ -0,0 +1,41 @@
+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/playback/DetectedSkip.kt b/app/src/main/java/org/terst/listen/playback/DetectedSkip.kt
new file mode 100644
index 0000000..188417a
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/playback/DetectedSkip.kt
@@ -0,0 +1,15 @@
+package org.terst.listen.playback
+
+import org.terst.listen.data.EpisodeEntity
+import org.terst.listen.data.SubscriptionEntity
+
+/**
+ * Where Tier 3 (automatically detected, per episode) and Tier 2 (typed in by hand, per show)
+ * actually meet: [org.terst.listen.playback.PlaybackProgress] has no idea a detector exists,
+ * and never will -- these two functions are the entire integration.
+ */
+fun effectiveIntroMs(episode: EpisodeEntity?, show: SubscriptionEntity?): Long =
+ episode?.detectedIntroMs ?: show?.skipIntroMs ?: 0
+
+fun effectiveOutroMs(episode: EpisodeEntity?, show: SubscriptionEntity?): Long =
+ episode?.detectedOutroMs ?: show?.skipOutroMs ?: 0
diff --git a/app/src/main/java/org/terst/listen/playback/PlaybackAdvancer.kt b/app/src/main/java/org/terst/listen/playback/PlaybackAdvancer.kt
index 92d2147..8aa0bf0 100644
--- a/app/src/main/java/org/terst/listen/playback/PlaybackAdvancer.kt
+++ b/app/src/main/java/org/terst/listen/playback/PlaybackAdvancer.kt
@@ -12,6 +12,7 @@ import org.terst.listen.feed.Chapter
data class PlayingContext(
val show: SubscriptionEntity? = null,
val chapters: List<Chapter> = emptyList(),
+ val episode: EpisodeEntity? = null,
)
/**
@@ -99,7 +100,7 @@ class PlaybackAdvancer(
val show = subscriptions.getById(episode.subscriptionId)
// Only worth fetching for a show that asked for it; most publish no chapters at all.
val chapters = if (show?.skipAdChapters == true) sources.chapters(episode) else emptyList()
- return PlayingContext(show, chapters)
+ return PlayingContext(show, chapters, episode)
}
/**
@@ -145,7 +146,7 @@ class PlaybackAdvancer(
startPositionMs = progress.resumePosition(
episode.positionMs,
episode.durationMs,
- show?.skipIntroMs ?: 0,
+ effectiveIntroMs(episode, show),
),
speed = PlaybackSpeed.forEpisode(episode.speedOverride, show?.playbackSpeed),
)
diff --git a/app/src/main/java/org/terst/listen/playback/PlaybackService.kt b/app/src/main/java/org/terst/listen/playback/PlaybackService.kt
index bdb0a38..6cd8db5 100644
--- a/app/src/main/java/org/terst/listen/playback/PlaybackService.kt
+++ b/app/src/main/java/org/terst/listen/playback/PlaybackService.kt
@@ -147,8 +147,7 @@ class PlaybackService : MediaLibraryService() {
private suspend fun applyPlayheadPolicy() {
val exo = player ?: return
val action = PlayheadPolicy.decide(
- show = context.show,
- chapters = context.chapters,
+ context = context,
positionMs = exo.currentPosition,
durationMs = exo.duration.takeIf { it > 0 },
progress = progress,
diff --git a/app/src/main/java/org/terst/listen/playback/PlayheadPolicy.kt b/app/src/main/java/org/terst/listen/playback/PlayheadPolicy.kt
index cdfc973..5d7f7f6 100644
--- a/app/src/main/java/org/terst/listen/playback/PlayheadPolicy.kt
+++ b/app/src/main/java/org/terst/listen/playback/PlayheadPolicy.kt
@@ -1,8 +1,5 @@
package org.terst.listen.playback
-import org.terst.listen.data.SubscriptionEntity
-import org.terst.listen.feed.Chapter
-
/** What the playback ticker should do about where the playhead currently is. */
sealed interface TickAction {
data class SeekTo(val positionMs: Long) : TickAction
@@ -20,22 +17,22 @@ sealed interface TickAction {
object PlayheadPolicy {
fun decide(
- show: SubscriptionEntity?,
- chapters: List<Chapter>,
+ context: PlayingContext,
positionMs: Long,
durationMs: Long?,
progress: PlaybackProgress,
): TickAction {
- if (show == null) return TickAction.Nothing
+ val show = context.show ?: return TickAction.Nothing
// Finishing beats skipping. A show with both an outro offset and a marked break at the
// end would otherwise seek into the credits it was told to skip, and sit there.
- if (show.skipOutroMs > 0 && progress.isComplete(positionMs, durationMs, show.skipOutroMs)) {
+ val outroMs = effectiveOutroMs(context.episode, show)
+ if (outroMs > 0 && progress.isComplete(positionMs, durationMs, outroMs)) {
return TickAction.Finish
}
if (!show.skipAdChapters) return TickAction.Nothing
- return AdChapters.skipTarget(chapters, positionMs, durationMs)
+ return AdChapters.skipTarget(context.chapters, positionMs, durationMs)
?.let(TickAction::SeekTo)
?: TickAction.Nothing
}
diff --git a/app/src/main/java/org/terst/listen/work/Workers.kt b/app/src/main/java/org/terst/listen/work/Workers.kt
index db896a6..44fd497 100644
--- a/app/src/main/java/org/terst/listen/work/Workers.kt
+++ b/app/src/main/java/org/terst/listen/work/Workers.kt
@@ -12,6 +12,8 @@ import androidx.work.WorkManager
import androidx.work.WorkerParameters
import org.terst.listen.data.Graph
import org.terst.listen.data.RefreshOutcome
+import org.terst.listen.fingerprint.FingerprintUpdater
+import org.terst.listen.fingerprint.MediaCodecClipSource
import java.util.concurrent.TimeUnit
/**
@@ -68,6 +70,29 @@ class RetentionWorker(context: Context, params: WorkerParameters) : CoroutineWor
}
}
+/**
+ * Checks one newly downloaded episode against its show's Tier 3 fingerprint state.
+ *
+ * The decode is [org.terst.listen.fingerprint.MediaCodecClipSource] (no JVM equivalent); the
+ * decision is [org.terst.listen.fingerprint.FingerprintUpdater], which is tested.
+ */
+class FingerprintWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
+
+ override suspend fun doWork(): Result {
+ val episodeId = inputData.getString(KEY_EPISODE_ID) ?: return Result.failure()
+ val graph = Graph.get(applicationContext)
+ val episode = graph.episodes.getById(episodeId) ?: return Result.success()
+
+ FingerprintUpdater(graph.fingerprints, MediaCodecClipSource()).process(episode)
+
+ return Result.success()
+ }
+
+ companion object {
+ const val KEY_EPISODE_ID = "episodeId"
+ }
+}
+
object Scheduling {
private const val REFRESH_WORK = "listen-refresh"
@@ -94,6 +119,20 @@ object Scheduling {
}
/**
+ * Checks one freshly downloaded episode against its show's fingerprint state.
+ *
+ * Not a unique work name: unlike a burst of queueing, a run of downloads is one episode
+ * each, and there is no "latest one wins" -- every episode needs its own check.
+ */
+ fun fingerprintNow(context: Context, episodeId: String) {
+ WorkManager.getInstance(context).enqueue(
+ OneTimeWorkRequestBuilder<FingerprintWorker>()
+ .setInputData(androidx.work.Data.Builder().putString(FingerprintWorker.KEY_EPISODE_ID, episodeId).build())
+ .build(),
+ )
+ }
+
+ /**
* The periodic jobs are KEEP rather than UPDATE, so the schedule survives an app restart
* instead of resetting its interval every launch -- which on a frequently-opened app means
* the periodic work never actually fires.
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 e270152..43f4067 100644
--- a/app/src/test/java/org/terst/listen/data/FakeDaos.kt
+++ b/app/src/test/java/org/terst/listen/data/FakeDaos.kt
@@ -190,6 +190,7 @@ class FakeEpisodeDao : EpisodeDao {
replace(id) { it.copy(chaptersJson = json) }
}
+
override suspend fun recordRule(id: String, ruleId: String, reason: String, speed: Float?) {
replace(id) { it.copy(appliedRuleId = ruleId, appliedRuleReason = reason, speedOverride = speed) }
}
@@ -242,6 +243,35 @@ class FakeRuleDao : RuleDao {
}
}
+class FakeShowFingerprintDao : ShowFingerprintDao {
+ val rows = mutableListOf<ShowFingerprintEntity>()
+
+ /** Wired by test setup to the same list backing a FakeEpisodeDao, so a detected value
+ * lands where a test can actually see it, the same as the real foreign table would. */
+ var episodeRows: MutableList<EpisodeEntity> = mutableListOf()
+
+ override suspend fun get(subscriptionId: String, kind: FingerprintKind) =
+ rows.firstOrNull { it.subscriptionId == subscriptionId && it.kind == kind }
+
+ override suspend fun upsert(fingerprint: ShowFingerprintEntity) {
+ rows.removeAll { it.subscriptionId == fingerprint.subscriptionId && it.kind == fingerprint.kind }
+ rows += fingerprint
+ }
+
+ override suspend fun setDetectedIntroMs(id: String, ms: Long) {
+ replaceEpisode(id) { it.copy(detectedIntroMs = ms) }
+ }
+
+ override suspend fun setDetectedOutroMs(id: String, ms: Long) {
+ replaceEpisode(id) { it.copy(detectedOutroMs = ms) }
+ }
+
+ private fun replaceEpisode(id: String, transform: (EpisodeEntity) -> EpisodeEntity) {
+ val index = episodeRows.indexOfFirst { it.id == id }
+ if (index >= 0) episodeRows[index] = transform(episodeRows[index])
+ }
+}
+
class FakeQueueDao : QueueDao {
val entries = mutableListOf<QueueEntity>()
var episodeSource: () -> List<EpisodeEntity> = { emptyList() }
diff --git a/app/src/test/java/org/terst/listen/data/ShowFingerprintEntityTest.kt b/app/src/test/java/org/terst/listen/data/ShowFingerprintEntityTest.kt
new file mode 100644
index 0000000..0cc9d7f
--- /dev/null
+++ b/app/src/test/java/org/terst/listen/data/ShowFingerprintEntityTest.kt
@@ -0,0 +1,57 @@
+package org.terst.listen.data
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotEquals
+import org.junit.Test
+
+/**
+ * A data class with a ByteArray property does not get structural equality for free -- Kotlin's
+ * generated equals/hashCode compare array *references*, so two rows read back from the database
+ * with byte-for-byte identical content would otherwise never be equal. This pins the hand-written
+ * override actually doing that job.
+ */
+class ShowFingerprintEntityTest {
+
+ private fun entity(pcm: ByteArray?, pending: ByteArray? = null) = ShowFingerprintEntity(
+ subscriptionId = "s1",
+ kind = FingerprintKind.INTRO,
+ referenceClipPcm = pcm,
+ pendingClipPcm = pending,
+ pendingEpisodeId = null,
+ sampleRate = 8_000,
+ confirmedCount = 3,
+ lastConfirmedEpisodeId = "e1",
+ updatedAtMs = 1000,
+ )
+
+ @Test
+ fun `two rows with byte-for-byte identical content are equal`() {
+ val a = entity(byteArrayOf(1, 2, 3))
+ val b = entity(byteArrayOf(1, 2, 3))
+ assertEquals(a, b)
+ assertEquals(a.hashCode(), b.hashCode())
+ }
+
+ @Test
+ fun `different clip bytes are not equal`() {
+ assertNotEquals(entity(byteArrayOf(1, 2, 3)), entity(byteArrayOf(1, 2, 4)))
+ }
+
+ @Test
+ fun `null and non-null clips are not equal`() {
+ assertNotEquals(entity(byteArrayOf(1, 2, 3)), entity(null))
+ }
+
+ @Test
+ fun `two rows with both clips null are equal`() {
+ assertEquals(entity(null, null), entity(null, null))
+ }
+
+ @Test
+ fun `differing pending clips are not equal even when the reference clip matches`() {
+ assertNotEquals(
+ entity(byteArrayOf(1, 2, 3), pending = byteArrayOf(9)),
+ entity(byteArrayOf(1, 2, 3), pending = byteArrayOf(8)),
+ )
+ }
+}
diff --git a/app/src/test/java/org/terst/listen/fingerprint/AudioCorrelationTest.kt b/app/src/test/java/org/terst/listen/fingerprint/AudioCorrelationTest.kt
new file mode 100644
index 0000000..6274e99
--- /dev/null
+++ b/app/src/test/java/org/terst/listen/fingerprint/AudioCorrelationTest.kt
@@ -0,0 +1,160 @@
+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))
+ }
+}
diff --git a/app/src/test/java/org/terst/listen/fingerprint/FingerprintUpdaterTest.kt b/app/src/test/java/org/terst/listen/fingerprint/FingerprintUpdaterTest.kt
new file mode 100644
index 0000000..e9cf5c3
--- /dev/null
+++ b/app/src/test/java/org/terst/listen/fingerprint/FingerprintUpdaterTest.kt
@@ -0,0 +1,129 @@
+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, 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) }
+ }
+}
diff --git a/app/src/test/java/org/terst/listen/fingerprint/IntroOutroDetectorTest.kt b/app/src/test/java/org/terst/listen/fingerprint/IntroOutroDetectorTest.kt
new file mode 100644
index 0000000..91f2800
--- /dev/null
+++ b/app/src/test/java/org/terst/listen/fingerprint/IntroOutroDetectorTest.kt
@@ -0,0 +1,191 @@
+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 = 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/PcmCodecTest.kt b/app/src/test/java/org/terst/listen/fingerprint/PcmCodecTest.kt
new file mode 100644
index 0000000..9267828
--- /dev/null
+++ b/app/src/test/java/org/terst/listen/fingerprint/PcmCodecTest.kt
@@ -0,0 +1,37 @@
+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)
+ }
+}
diff --git a/app/src/test/java/org/terst/listen/playback/PlayheadPolicyTest.kt b/app/src/test/java/org/terst/listen/playback/PlayheadPolicyTest.kt
index d0a49f7..92ab8fe 100644
--- a/app/src/test/java/org/terst/listen/playback/PlayheadPolicyTest.kt
+++ b/app/src/test/java/org/terst/listen/playback/PlayheadPolicyTest.kt
@@ -34,7 +34,7 @@ class PlayheadPolicyTest {
chapters: List<Chapter> = emptyList(),
positionMs: Long,
durationMs: Long? = 30 * minute,
- ) = PlayheadPolicy.decide(show, chapters, positionMs, durationMs, progress)
+ ) = PlayheadPolicy.decide(PlayingContext(show, chapters), positionMs, durationMs, progress)
@Test
fun `mid-episode, with nothing configured, there is nothing to do`() {