diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-09-26 12:57:26 -1000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-09-26 12:57:26 -1000 |
| commit | 4e37185852ef3d1a2ed6348dd4c513648dafdd5d (patch) | |
| tree | e6142a1ed2d5f8de77b293df2602f2e49fc85fd3 | |
| parent | 6a1847b391b87969a0fc4682a8fe33642d98105d (diff) | |
Tier 3 skip detection: audio fingerprinting, prototyped and built
Closes the last deferred Phase 2 item. Desktop-prototyped first, per the
design plan's own instruction, against three real shows (see the prototype
findings recorded in memory): NPR Up First (a clean ~6s intro sting, r
0.96-0.998 across every pair tested), Tangle (a long 60-90s open/sponsor
read, equally reliable), and Darknet Diaries (no real intro -- a segment
that matched between two temporally-close episodes turned out to be a
dynamically-inserted ad, confirmed by checking a 2018 episode that matched
the same "intro" despite the multi-year gap).
That last finding drove the actual design: IntroOutroDetector requires 3+
independently-fetched episodes to agree before trusting a candidate and
auto-applying it, which is exactly what the Darknet Diaries ad would not
have survived. The confirmation threshold is the safety mechanism, not
just caution for its own sake -- and it's now a parameter, not a hardcoded
assumption, after coverage review found the "still tentative" branch was
unreachable at the original threshold.
- AudioCorrelation: FFT-based normalized cross-correlation, a Kotlin port
of the validated prototype. Tested against numpy/scipy-derived ground
truth fixtures, not just self-consistency -- the prototype's own
normalization bug (global mean instead of each window's local mean,
producing a bogus 0.98 "match" plain correlation put at -0.02) is exactly
what that guards against being ported here unnoticed.
- IntroOutroDetector: the pure state machine (waiting/tentative/confirmed/
discard-on-mismatch), mirroring DripSchedule's shape.
- MediaCodecClipSource: the one Android-only piece (MediaExtractor/
MediaCodec decode to mono 8kHz PCM), on the framework-adapter coverage
exclusion list for the same reason PlayerConnection is.
- Storage: AppDatabase 6->7, a new show_fingerprints table (one row per
show per intro/outro, tracking the current candidate clip and how many
episodes have confirmed it) and two new nullable EpisodeEntity columns.
Tier 2's manual per-show offset remains the fallback -- PlaybackProgress
itself is unchanged; DetectedSkip.kt is the entire integration surface.
- Trigger: EpisodeDownloader gained an onDownloaded hook (every download
route ends up there, manual or queued) firing a new one-shot
FingerprintWorker per episode.
Coverage improved (90.13% -> 91.70%): the new pure logic is fully tested,
and closing two real gaps the coverage review surfaced (an untested
first-two-episodes-disagree path, and ShowFingerprintEntity's hand-written
equals/hashCode, needed because a ByteArray property breaks the generated
one) accounted for more than the new Android-glue lines cost.
Not yet verified on a device -- no phone connected this session. The
MigrationTest 6->7 case and the actual decode/match pipeline both need a
real run before this is trusted with real playback.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sj3CoWEedM56bebByKgNxA
27 files changed, 2220 insertions, 17 deletions
diff --git a/.coverage-baseline b/.coverage-baseline index 28953be..944bcd0 100644 --- a/.coverage-baseline +++ b/.coverage-baseline @@ -1 +1 @@ -90.13 +91.70 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f0ae29f..f76f892 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -284,6 +284,12 @@ val coverageExclusions = listOf( "**/data/Graph*", "**/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*", ) tasks.register<JacocoReport>("jacocoTestReport") { diff --git a/app/schemas/org.terst.listen.data.AppDatabase/7.json b/app/schemas/org.terst.listen.data.AppDatabase/7.json new file mode 100644 index 0000000..20b92c3 --- /dev/null +++ b/app/schemas/org.terst.listen.data.AppDatabase/7.json @@ -0,0 +1,596 @@ +{ + "formatVersion": 1, + "database": { + "version": 7, + "identityHash": "564d893d23f7e7f14cb4afc1b65c83f0", + "entities": [ + { + "tableName": "subscriptions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `feedUrl` TEXT NOT NULL, `kind` TEXT NOT NULL, `title` TEXT NOT NULL, `description` TEXT, `author` TEXT, `imageUrl` TEXT, `link` TEXT, `addedAtMs` INTEGER NOT NULL, `etag` TEXT, `lastModified` TEXT, `lastFetchedAtMs` INTEGER, `lastError` TEXT, `playbackSpeed` REAL, `skipIntroMs` INTEGER NOT NULL, `skipOutroMs` INTEGER NOT NULL, `dripEnabled` INTEGER NOT NULL DEFAULT 0, `dripIntervalMs` INTEGER NOT NULL DEFAULT 86400000, `dripCount` INTEGER NOT NULL DEFAULT 1, `dripOldestFirst` INTEGER NOT NULL DEFAULT 1, `dripLastReleaseAtMs` INTEGER, `skipAdChapters` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "feedUrl", + "columnName": "feedUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT" + }, + { + "fieldPath": "imageUrl", + "columnName": "imageUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT" + }, + { + "fieldPath": "addedAtMs", + "columnName": "addedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT" + }, + { + "fieldPath": "lastModified", + "columnName": "lastModified", + "affinity": "TEXT" + }, + { + "fieldPath": "lastFetchedAtMs", + "columnName": "lastFetchedAtMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastError", + "columnName": "lastError", + "affinity": "TEXT" + }, + { + "fieldPath": "playbackSpeed", + "columnName": "playbackSpeed", + "affinity": "REAL" + }, + { + "fieldPath": "skipIntroMs", + "columnName": "skipIntroMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "skipOutroMs", + "columnName": "skipOutroMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "dripEnabled", + "columnName": "dripEnabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "dripIntervalMs", + "columnName": "dripIntervalMs", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "86400000" + }, + { + "fieldPath": "dripCount", + "columnName": "dripCount", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "dripOldestFirst", + "columnName": "dripOldestFirst", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "dripLastReleaseAtMs", + "columnName": "dripLastReleaseAtMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "skipAdChapters", + "columnName": "skipAdChapters", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_subscriptions_feedUrl", + "unique": true, + "columnNames": [ + "feedUrl" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_subscriptions_feedUrl` ON `${TABLE_NAME}` (`feedUrl`)" + } + ] + }, + { + "tableName": "episodes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `subscriptionId` TEXT NOT NULL, `guid` TEXT NOT NULL, `title` TEXT NOT NULL, `description` TEXT, `mediaUrl` TEXT NOT NULL, `mediaType` TEXT NOT NULL, `mediaLengthBytes` INTEGER, `durationMs` INTEGER, `publishedAtMs` INTEGER, `seasonNumber` INTEGER, `episodeNumber` INTEGER, `imageUrl` TEXT, `chaptersUrl` TEXT, `discoveredAtMs` INTEGER NOT NULL, `positionMs` INTEGER NOT NULL, `playedAtMs` INTEGER, `downloadState` TEXT NOT NULL, `localPath` TEXT, `downloadedAtMs` INTEGER, `dripReleasedAtMs` INTEGER, `appliedRuleId` TEXT, `appliedRuleReason` TEXT, `speedOverride` REAL, `chaptersJson` TEXT, `detectedIntroMs` INTEGER, `detectedOutroMs` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`subscriptionId`) REFERENCES `subscriptions`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "subscriptionId", + "columnName": "subscriptionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "guid", + "columnName": "guid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "mediaUrl", + "columnName": "mediaUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mediaType", + "columnName": "mediaType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mediaLengthBytes", + "columnName": "mediaLengthBytes", + "affinity": "INTEGER" + }, + { + "fieldPath": "durationMs", + "columnName": "durationMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "publishedAtMs", + "columnName": "publishedAtMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "seasonNumber", + "columnName": "seasonNumber", + "affinity": "INTEGER" + }, + { + "fieldPath": "episodeNumber", + "columnName": "episodeNumber", + "affinity": "INTEGER" + }, + { + "fieldPath": "imageUrl", + "columnName": "imageUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "chaptersUrl", + "columnName": "chaptersUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "discoveredAtMs", + "columnName": "discoveredAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "positionMs", + "columnName": "positionMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playedAtMs", + "columnName": "playedAtMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "downloadState", + "columnName": "downloadState", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "localPath", + "columnName": "localPath", + "affinity": "TEXT" + }, + { + "fieldPath": "downloadedAtMs", + "columnName": "downloadedAtMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "dripReleasedAtMs", + "columnName": "dripReleasedAtMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "appliedRuleId", + "columnName": "appliedRuleId", + "affinity": "TEXT" + }, + { + "fieldPath": "appliedRuleReason", + "columnName": "appliedRuleReason", + "affinity": "TEXT" + }, + { + "fieldPath": "speedOverride", + "columnName": "speedOverride", + "affinity": "REAL" + }, + { + "fieldPath": "chaptersJson", + "columnName": "chaptersJson", + "affinity": "TEXT" + }, + { + "fieldPath": "detectedIntroMs", + "columnName": "detectedIntroMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "detectedOutroMs", + "columnName": "detectedOutroMs", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_episodes_subscriptionId", + "unique": false, + "columnNames": [ + "subscriptionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_episodes_subscriptionId` ON `${TABLE_NAME}` (`subscriptionId`)" + }, + { + "name": "index_episodes_subscriptionId_guid", + "unique": true, + "columnNames": [ + "subscriptionId", + "guid" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_episodes_subscriptionId_guid` ON `${TABLE_NAME}` (`subscriptionId`, `guid`)" + }, + { + "name": "index_episodes_publishedAtMs", + "unique": false, + "columnNames": [ + "publishedAtMs" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_episodes_publishedAtMs` ON `${TABLE_NAME}` (`publishedAtMs`)" + } + ], + "foreignKeys": [ + { + "table": "subscriptions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "subscriptionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "queue", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`episodeId` TEXT NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`episodeId`), FOREIGN KEY(`episodeId`) REFERENCES `episodes`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "episodeId", + "columnName": "episodeId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "episodeId" + ] + }, + "indices": [ + { + "name": "index_queue_position", + "unique": false, + "columnNames": [ + "position" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_queue_position` ON `${TABLE_NAME}` (`position`)" + } + ], + "foreignKeys": [ + { + "table": "episodes", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "episodeId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `position` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `subscriptionId` TEXT, `titleContains` TEXT, `titleExcludes` TEXT, `minDurationMs` INTEGER, `maxDurationMs` INTEGER, `maxAgeMs` INTEGER, `seasonNumber` INTEGER, `action` TEXT NOT NULL, `autoDownload` INTEGER NOT NULL, `speed` REAL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subscriptionId", + "columnName": "subscriptionId", + "affinity": "TEXT" + }, + { + "fieldPath": "titleContains", + "columnName": "titleContains", + "affinity": "TEXT" + }, + { + "fieldPath": "titleExcludes", + "columnName": "titleExcludes", + "affinity": "TEXT" + }, + { + "fieldPath": "minDurationMs", + "columnName": "minDurationMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "maxDurationMs", + "columnName": "maxDurationMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "maxAgeMs", + "columnName": "maxAgeMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "seasonNumber", + "columnName": "seasonNumber", + "affinity": "INTEGER" + }, + { + "fieldPath": "action", + "columnName": "action", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "autoDownload", + "columnName": "autoDownload", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "speed", + "columnName": "speed", + "affinity": "REAL" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_rules_position", + "unique": false, + "columnNames": [ + "position" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_rules_position` ON `${TABLE_NAME}` (`position`)" + } + ] + }, + { + "tableName": "show_fingerprints", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`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 UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "subscriptionId", + "columnName": "subscriptionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "referenceClipPcm", + "columnName": "referenceClipPcm", + "affinity": "BLOB" + }, + { + "fieldPath": "pendingClipPcm", + "columnName": "pendingClipPcm", + "affinity": "BLOB" + }, + { + "fieldPath": "pendingEpisodeId", + "columnName": "pendingEpisodeId", + "affinity": "TEXT" + }, + { + "fieldPath": "sampleRate", + "columnName": "sampleRate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "confirmedCount", + "columnName": "confirmedCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastConfirmedEpisodeId", + "columnName": "lastConfirmedEpisodeId", + "affinity": "TEXT" + }, + { + "fieldPath": "updatedAtMs", + "columnName": "updatedAtMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "subscriptionId", + "kind" + ] + }, + "indices": [ + { + "name": "index_show_fingerprints_subscriptionId", + "unique": false, + "columnNames": [ + "subscriptionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_show_fingerprints_subscriptionId` ON `${TABLE_NAME}` (`subscriptionId`)" + } + ], + "foreignKeys": [ + { + "table": "subscriptions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "subscriptionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '564d893d23f7e7f14cb4afc1b65c83f0')" + ] + } +}
\ No newline at end of file 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`() { |
