From 14ce4bc86820e0bfb9da7c7e620768bc2260466a Mon Sep 17 00:00:00 2001 From: Bartolo <162994065+BartolomeoRusso9@users.noreply.github.com> Date: Wed, 23 Sep 2026 22:29:15 +0200 Subject: [PATCH] Add Hi-Res authenticity check Introduce a per-file Hi-Res authenticity checker and optional auto re-download for flagged fake Hi-Res downloads. Adds Rust checker modules (fft, evidence, FLAC/WAV decoding, tests) and a mobile API (check_hires_authenticity). Wire platform bindings for Android/iOS and PlatformBridge. Add Dart HiResCheckService, UI HiResCheckCard, settings toggle (redownloadFakeHiRes) and integration in the download pipeline to quarantine and replace suspicious files with LOSSLESS when safe. Adds claxon FLAC dependency. --- .../kotlin/com/zarz/spotiflac/CoreBackend.kt | 1 + .../kotlin/com/zarz/spotiflac/MainActivity.kt | 19 + .../com/zarz/spotiflac/MainActivitySafIo.kt | 9 + .../com/zarz/spotiflac/SelectedCoreBackend.kt | 3 + ios/Runner/AppDelegate.swift | 5 + ios/Runner/CoreBackend.swift | 5 + lib/l10n/arb/app_en.arb | 125 +++ lib/l10n/arb/app_it.arb | 125 +++ lib/models/settings.dart | 5 + lib/models/settings.g.dart | 2 + lib/providers/download_queue_provider.dart | 2 + .../download_queue_provider_hires_check.dart | 224 ++++++ ...download_queue_provider_native_worker.dart | 8 + .../download_queue_provider_single_item.dart | 15 + lib/providers/settings_provider.dart | 5 + .../settings/download_settings_page.dart | 9 + .../settings/settings_search_catalog.dart | 6 + lib/screens/track_metadata_cards.dart | 10 + lib/screens/track_metadata_screen.dart | 1 + lib/services/hires_check_service.dart | 109 +++ lib/services/platform_bridge.dart | 12 + lib/widgets/hires_check_card.dart | 308 ++++++++ rust_backend/Cargo.lock | 7 + rust_backend/Cargo.toml | 2 + rust_backend/crates/core/Cargo.toml | 1 + .../crates/core/src/media/hires/evidence.rs | 491 ++++++++++++ .../crates/core/src/media/hires/fft.rs | 53 ++ .../crates/core/src/media/hires/mod.rs | 634 +++++++++++++++ .../crates/core/src/media/hires/tests.rs | 736 ++++++++++++++++++ rust_backend/crates/core/src/media/mod.rs | 1 + rust_backend/crates/mobile/src/hires.rs | 76 ++ rust_backend/crates/mobile/src/lib.rs | 1 + rust_backend/crates/mobile/src/tags.rs | 4 +- 33 files changed, 3012 insertions(+), 2 deletions(-) create mode 100644 lib/providers/download_queue_provider_hires_check.dart create mode 100644 lib/services/hires_check_service.dart create mode 100644 lib/widgets/hires_check_card.dart create mode 100644 rust_backend/crates/core/src/media/hires/evidence.rs create mode 100644 rust_backend/crates/core/src/media/hires/fft.rs create mode 100644 rust_backend/crates/core/src/media/hires/mod.rs create mode 100644 rust_backend/crates/core/src/media/hires/tests.rs create mode 100644 rust_backend/crates/mobile/src/hires.rs diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/CoreBackend.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/CoreBackend.kt index 84ad51b1..69acea31 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/CoreBackend.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/CoreBackend.kt @@ -195,6 +195,7 @@ internal interface CoreBackend { fun sanitizeFilename(filename: String): String fun fileMetadataImplementation(path: String): String fun readFileMetadata(path: String, hint: String): String + fun checkHiResAuthenticity(path: String, optionsJson: String): String fun readAudioMetadata(path: String, hint: String, cacheKey: String): String fun setLibraryCoverCacheDirectory(path: String) fun scanLibraryFolder(folder: String): String diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index c7d5878c..df329ca9 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -86,6 +86,7 @@ class MainActivity: FlutterFragmentActivity() { "runPostProcessingV2", "readAudioMetadata", "readFileMetadata", + "checkHiResAuthenticity", "editFileMetadata", "reEnrichFile", "setLibraryCoverCacheDir", @@ -1442,6 +1443,24 @@ class MainActivity: FlutterFragmentActivity() { } result.success(response) } + "checkHiResAuthenticity" -> { + val filePath = call.argument("file_path") ?: "" + val optionsJson = call.argument("options_json") ?: "" + val response = withContext(Dispatchers.IO) { + try { + if (filePath.startsWith("content://")) { + checkHiResAuthenticityFromUri(Uri.parse(filePath), optionsJson) + ?.toString() + ?: errorJson("Failed to read SAF audio file") + } else { + coreBackend.checkHiResAuthenticity(filePath, optionsJson) + } + } catch (e: Exception) { + errorJson(e.message ?: "Hi-Res check failed") + } + } + result.success(response) + } "editFileMetadata" -> { val filePath = call.argument("file_path") ?: "" val metadataJson = call.argument("metadata_json") ?: "{}" diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafIo.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafIo.kt index ddf92963..e5449f51 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafIo.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafIo.kt @@ -299,6 +299,15 @@ internal fun MainActivity.readAudioMetadataFromUri( obj.takeUnless { it.has("error") } } +/** The Hi-Res check reads only a window from the middle of the file, so a + * seekable descriptor avoids copying the whole track out of SAF. */ +internal fun MainActivity.checkHiResAuthenticityFromUri( + uri: Uri, + optionsJson: String, +): JSONObject? = readMetadataFromUri(uri) { path, _ -> + JSONObject(coreBackend.checkHiResAuthenticity(path, optionsJson)) +}?.put("file_path", uri.toString()) + internal fun MainActivity.readCompleteMetadataFromUri( uri: Uri, displayNameHint: String? = null, diff --git a/android/app/src/rust/kotlin/com/zarz/spotiflac/SelectedCoreBackend.kt b/android/app/src/rust/kotlin/com/zarz/spotiflac/SelectedCoreBackend.kt index 476ca74e..d0d2ca86 100644 --- a/android/app/src/rust/kotlin/com/zarz/spotiflac/SelectedCoreBackend.kt +++ b/android/app/src/rust/kotlin/com/zarz/spotiflac/SelectedCoreBackend.kt @@ -218,6 +218,9 @@ internal object RustCoreBackend : CoreBackend { override fun readFileMetadata(path: String, hint: String): String = com.spotiflac.backend.readFileMetadata(path, hint, null) + override fun checkHiResAuthenticity(path: String, optionsJson: String): String = + com.spotiflac.backend.checkHiresAuthenticity(path, optionsJson, null) + override fun readAudioMetadata(path: String, hint: String, cacheKey: String): String = owner().readAudioMetadata(File(path).canonicalPath, hint, cacheKey, null) diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 1c4dc381..d8132aa7 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -464,6 +464,11 @@ import UniformTypeIdentifiers let filePath = args["file_path"] as! String return try coreBackend.readFileMetadata(path: filePath, hint: args["display_name"] as? String ?? "") + case "checkHiResAuthenticity": + let args = call.arguments as! [String: Any] + let filePath = args["file_path"] as! String + return try coreBackend.checkHiResAuthenticity(path: filePath, optionsJson: args["options_json"] as? String ?? "") + case "editFileMetadata": let args = call.arguments as! [String: Any] let filePath = args["file_path"] as! String diff --git a/ios/Runner/CoreBackend.swift b/ios/Runner/CoreBackend.swift index bb577bb7..6401180e 100644 --- a/ios/Runner/CoreBackend.swift +++ b/ios/Runner/CoreBackend.swift @@ -164,6 +164,7 @@ protocol CoreBackend { func sanitizeFilename(filename: String) -> String func fileMetadataImplementation(path: String) -> String func readFileMetadata(path: String, hint: String) throws -> String + func checkHiResAuthenticity(path: String, optionsJson: String) throws -> String func editFileMetadata(path: String, metadataJson: String) throws -> String func reEnrichFile(requestJson: String) throws -> String func rewriteSplitArtistTags(path: String, artist: String, albumArtist: String) throws -> String @@ -483,6 +484,10 @@ final class RustCoreBackend: CoreBackend { try SpotiFLACBackend.readFileMetadata(path: path, hint: hint, lease: nil) } + func checkHiResAuthenticity(path: String, optionsJson: String) throws -> String { + try SpotiFLACBackend.checkHiresAuthenticity(path: path, optionsJson: optionsJson, lease: nil) + } + func editFileMetadata(path: String, metadataJson: String) throws -> String { try owner().editFileMetadata(path: URL(fileURLWithPath: path).resolvingSymlinksInPath().standardizedFileURL.path, metadataJson: metadataJson, lease: nil) } diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 3427f762..53a5bc2b 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -4856,6 +4856,131 @@ "@audioAnalysisRescan": { "description": "Tooltip/label for the button that re-runs the audio analysis, discarding cached results" }, + "downloadRedownloadFakeHiRes": "Re-download fake Hi-Res", + "@downloadRedownloadFakeHiRes": { + "description": "Setting title: automatically replace downloads that measure as fake Hi-Res" + }, + "downloadRedownloadFakeHiResSubtitle": "Check each Hi-Res FLAC after download and download it again as Lossless when it is certainly or very likely fake. Possible filtered masters are kept", + "@downloadRedownloadFakeHiResSubtitle": { + "description": "Setting subtitle for the fake Hi-Res re-download toggle" + }, + "hiResCheckVerdictFakeCertain": "Fake Hi-Res", + "@hiResCheckVerdictFakeCertain": { + "description": "Verdict: an exact fingerprint (padded bits or upsampling pattern) proves the file is not real Hi-Res" + }, + "hiResCheckVerdictFakeSuspect": "Possibly a filtered master", + "@hiResCheckVerdictFakeSuspect": { + "description": "Verdict: fails the cutoff test but may be a genuine master low-pass filtered in mastering" + }, + "hiResCheckReasonSampleHold": "Every sample is repeated: upsampled by duplication", + "@hiResCheckReasonSampleHold": { + "description": "Reason: sample-and-hold upsampling detected" + }, + "hiResCheckReasonInterpolation": "In-between samples are linearly interpolated", + "@hiResCheckReasonInterpolation": { + "description": "Reason: linear-interpolation upsampling detected" + }, + "hiResCheckReasonImaging": "Content above 22 kHz mirrors the audible band", + "@hiResCheckReasonImaging": { + "description": "Reason: spectral imaging from upsampling detected" + }, + "hiResCheckMusicContent": "Musical content", + "@hiResCheckMusicContent": { + "description": "Label for the highest frequency that still moves with the music" + }, + "hiResCheckUltrasonicNoise": "Above {cutoff} only steady noise (typical of DSD or analog tape transfers): a {rate} file would hold all the music", + "@hiResCheckUltrasonicNoise": { + "description": "Shown when the ultrasonic content of a Hi-Res file is noise rather than music", + "placeholders": { + "cutoff": { + "type": "String" + }, + "rate": { + "type": "String" + } + } + }, + "hiResCheckTitle": "Hi-Res Authenticity", + "@hiResCheckTitle": { + "description": "Title of the fake Hi-Res check card" + }, + "hiResCheckDescription": "Detect upsampled or bit-padded fake Hi-Res files", + "@hiResCheckDescription": { + "description": "Explains what the Hi-Res authenticity check does" + }, + "hiResCheckRun": "Check", + "@hiResCheckRun": { + "description": "Button that starts the Hi-Res authenticity check" + }, + "hiResCheckChecking": "Checking Hi-Res authenticity...", + "@hiResCheckChecking": { + "description": "Loading text while the Hi-Res check runs" + }, + "hiResCheckVerdictFake": "Likely fake Hi-Res", + "@hiResCheckVerdictFake": { + "description": "Verdict: the file claims Hi-Res but does not measure as it" + }, + "hiResCheckVerdictGenuine": "Measures as genuine Hi-Res", + "@hiResCheckVerdictGenuine": { + "description": "Verdict: no upsampling or padded bit depth found" + }, + "hiResCheckVerdictStandard": "Standard definition, nothing to flag", + "@hiResCheckVerdictStandard": { + "description": "Verdict: neither sample rate nor bit depth claims Hi-Res" + }, + "hiResCheckVerdictInconclusive": "Inconclusive: the analyzed segment is too quiet or short", + "@hiResCheckVerdictInconclusive": { + "description": "Verdict: analysis could not reach a reliable conclusion" + }, + "hiResCheckUnsupported": "Only FLAC and WAV files can be checked", + "@hiResCheckUnsupported": { + "description": "Shown when the file format cannot be decoded by the Hi-Res check" + }, + "hiResCheckCutoff": "Content cutoff", + "@hiResCheckCutoff": { + "description": "Label for the highest frequency with real content" + }, + "hiResCheckBitsInUse": "Bits in use", + "@hiResCheckBitsInUse": { + "description": "Label for effective vs declared bit depth" + }, + "hiResCheckDisclaimer": "The spectrum stops early, but nothing proves it was upsampled: it may be a genuine master low-pass filtered in mastering. It is not replaced automatically.", + "@hiResCheckDisclaimer": { + "description": "Caveat shown under a fake Hi-Res verdict" + }, + "hiResCheckFailed": "Hi-Res check failed: {error}", + "@hiResCheckFailed": { + "description": "Error shown when the Hi-Res check fails", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "hiResCheckReasonRate": "Declares {sampleRate} but content stops at ~{cutoff}", + "@hiResCheckReasonRate": { + "description": "Reason for a fake verdict caused by upsampling", + "placeholders": { + "sampleRate": { + "type": "String" + }, + "cutoff": { + "type": "String" + } + } + }, + "hiResCheckReasonDepth": "Declares {declared}-bit but only {effective} bits carry data", + "@hiResCheckReasonDepth": { + "description": "Reason for a fake verdict caused by padded bit depth", + "placeholders": { + "declared": { + "type": "int" + }, + "effective": { + "type": "int" + } + } + }, "audioAnalysisRescanning": "Re-analyzing audio...", "@audioAnalysisRescanning": { "description": "Loading text while audio is being re-analyzed after an explicit refresh" diff --git a/lib/l10n/arb/app_it.arb b/lib/l10n/arb/app_it.arb index 68898777..5e9f703e 100644 --- a/lib/l10n/arb/app_it.arb +++ b/lib/l10n/arb/app_it.arb @@ -4732,6 +4732,131 @@ "@audioAnalysisRescan": { "description": "Tooltip/label for the button that re-runs the audio analysis, discarding cached results" }, + "downloadRedownloadFakeHiRes": "Riscarica i falsi Hi-Res", + "@downloadRedownloadFakeHiRes": { + "description": "Setting title: automatically replace downloads that measure as fake Hi-Res" + }, + "downloadRedownloadFakeHiResSubtitle": "Verifica ogni FLAC Hi-Res dopo il download e lo riscarica in Lossless quando è falso con certezza o quasi. I possibili master filtrati vengono tenuti", + "@downloadRedownloadFakeHiResSubtitle": { + "description": "Setting subtitle for the fake Hi-Res re-download toggle" + }, + "hiResCheckVerdictFakeCertain": "Falso Hi-Res", + "@hiResCheckVerdictFakeCertain": { + "description": "Verdict: an exact fingerprint (padded bits or upsampling pattern) proves the file is not real Hi-Res" + }, + "hiResCheckVerdictFakeSuspect": "Forse un master filtrato", + "@hiResCheckVerdictFakeSuspect": { + "description": "Verdict: fails the cutoff test but may be a genuine master low-pass filtered in mastering" + }, + "hiResCheckReasonSampleHold": "Ogni campione è ripetuto: ricampionato per duplicazione", + "@hiResCheckReasonSampleHold": { + "description": "Reason: sample-and-hold upsampling detected" + }, + "hiResCheckReasonInterpolation": "I campioni intermedi sono interpolati linearmente", + "@hiResCheckReasonInterpolation": { + "description": "Reason: linear-interpolation upsampling detected" + }, + "hiResCheckReasonImaging": "Il contenuto sopra i 22 kHz è lo specchio della banda udibile", + "@hiResCheckReasonImaging": { + "description": "Reason: spectral imaging from upsampling detected" + }, + "hiResCheckMusicContent": "Contenuto musicale", + "@hiResCheckMusicContent": { + "description": "Label for the highest frequency that still moves with the music" + }, + "hiResCheckUltrasonicNoise": "Sopra {cutoff} solo rumore costante (tipico dei trasferimenti da DSD o nastro analogico): un file a {rate} conterrebbe tutta la musica", + "@hiResCheckUltrasonicNoise": { + "description": "Shown when the ultrasonic content of a Hi-Res file is noise rather than music", + "placeholders": { + "cutoff": { + "type": "String" + }, + "rate": { + "type": "String" + } + } + }, + "hiResCheckTitle": "Autenticità Hi-Res", + "@hiResCheckTitle": { + "description": "Title of the fake Hi-Res check card" + }, + "hiResCheckDescription": "Rileva file Hi-Res falsi, ricampionati o con bit riempiti", + "@hiResCheckDescription": { + "description": "Explains what the Hi-Res authenticity check does" + }, + "hiResCheckRun": "Verifica", + "@hiResCheckRun": { + "description": "Button that starts the Hi-Res authenticity check" + }, + "hiResCheckChecking": "Verifica dell'autenticità Hi-Res...", + "@hiResCheckChecking": { + "description": "Loading text while the Hi-Res check runs" + }, + "hiResCheckVerdictFake": "Probabile falso Hi-Res", + "@hiResCheckVerdictFake": { + "description": "Verdict: the file claims Hi-Res but does not measure as it" + }, + "hiResCheckVerdictGenuine": "Risulta Hi-Res autentico", + "@hiResCheckVerdictGenuine": { + "description": "Verdict: no upsampling or padded bit depth found" + }, + "hiResCheckVerdictStandard": "Definizione standard, niente da segnalare", + "@hiResCheckVerdictStandard": { + "description": "Verdict: neither sample rate nor bit depth claims Hi-Res" + }, + "hiResCheckVerdictInconclusive": "Inconcludente: il segmento analizzato è troppo silenzioso o breve", + "@hiResCheckVerdictInconclusive": { + "description": "Verdict: analysis could not reach a reliable conclusion" + }, + "hiResCheckUnsupported": "Si possono verificare solo file FLAC e WAV", + "@hiResCheckUnsupported": { + "description": "Shown when the file format cannot be decoded by the Hi-Res check" + }, + "hiResCheckCutoff": "Taglio del contenuto", + "@hiResCheckCutoff": { + "description": "Label for the highest frequency with real content" + }, + "hiResCheckBitsInUse": "Bit usati", + "@hiResCheckBitsInUse": { + "description": "Label for effective vs declared bit depth" + }, + "hiResCheckDisclaimer": "Lo spettro si ferma presto, ma nulla prova che sia ricampionato: potrebbe essere un master autentico filtrato in mastering. Non viene sostituito automaticamente.", + "@hiResCheckDisclaimer": { + "description": "Caveat shown under a fake Hi-Res verdict" + }, + "hiResCheckFailed": "Verifica Hi-Res non riuscita: {error}", + "@hiResCheckFailed": { + "description": "Error shown when the Hi-Res check fails", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "hiResCheckReasonRate": "Dichiara {sampleRate} ma il contenuto si ferma a ~{cutoff}", + "@hiResCheckReasonRate": { + "description": "Reason for a fake verdict caused by upsampling", + "placeholders": { + "sampleRate": { + "type": "String" + }, + "cutoff": { + "type": "String" + } + } + }, + "hiResCheckReasonDepth": "Dichiara {declared} bit ma solo {effective} contengono dati", + "@hiResCheckReasonDepth": { + "description": "Reason for a fake verdict caused by padded bit depth", + "placeholders": { + "declared": { + "type": "int" + }, + "effective": { + "type": "int" + } + } + }, "audioAnalysisRescanning": "Re-analyzing audio...", "@audioAnalysisRescanning": { "description": "Loading text while audio is being re-analyzed after an explicit refresh" diff --git a/lib/models/settings.dart b/lib/models/settings.dart index f32b5f0d..e407cae2 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -77,6 +77,8 @@ class AppSettings { final String locale; final String lyricsMode; final bool autoConvertDownloads; + // Re-download a Hi-Res request at LOSSLESS when the file measures as fake. + final bool redownloadFakeHiRes; final String autoConvertFormat; // 'mp3', 'aac' (M4A), or 'opus' final String autoConvertBitrate; // '128k', '192k', '256k', or '320k' final bool @@ -172,6 +174,7 @@ class AppSettings { this.locale = 'system', this.lyricsMode = 'embed', this.autoConvertDownloads = false, + this.redownloadFakeHiRes = false, this.autoConvertFormat = 'mp3', this.autoConvertBitrate = '320k', this.useAllFilesAccess = false, @@ -260,6 +263,7 @@ class AppSettings { String? locale, String? lyricsMode, bool? autoConvertDownloads, + bool? redownloadFakeHiRes, String? autoConvertFormat, String? autoConvertBitrate, bool? useAllFilesAccess, @@ -353,6 +357,7 @@ class AppSettings { locale: locale ?? this.locale, lyricsMode: lyricsMode ?? this.lyricsMode, autoConvertDownloads: autoConvertDownloads ?? this.autoConvertDownloads, + redownloadFakeHiRes: redownloadFakeHiRes ?? this.redownloadFakeHiRes, autoConvertFormat: autoConvertFormat ?? this.autoConvertFormat, autoConvertBitrate: autoConvertBitrate ?? this.autoConvertBitrate, useAllFilesAccess: useAllFilesAccess ?? this.useAllFilesAccess, diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index 9e0fe714..c4e68e6e 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -63,6 +63,7 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( locale: json['locale'] as String? ?? 'system', lyricsMode: json['lyricsMode'] as String? ?? 'embed', autoConvertDownloads: json['autoConvertDownloads'] as bool? ?? false, + redownloadFakeHiRes: json['redownloadFakeHiRes'] as bool? ?? false, autoConvertFormat: json['autoConvertFormat'] as String? ?? 'mp3', autoConvertBitrate: json['autoConvertBitrate'] as String? ?? '320k', useAllFilesAccess: json['useAllFilesAccess'] as bool? ?? false, @@ -152,6 +153,7 @@ Map _$AppSettingsToJson( 'locale': instance.locale, 'lyricsMode': instance.lyricsMode, 'autoConvertDownloads': instance.autoConvertDownloads, + 'redownloadFakeHiRes': instance.redownloadFakeHiRes, 'autoConvertFormat': instance.autoConvertFormat, 'autoConvertBitrate': instance.autoConvertBitrate, 'useAllFilesAccess': instance.useAllFilesAccess, diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 8453560f..8c89eb0a 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -25,6 +25,7 @@ import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/download_request_payload.dart'; import 'package:spotiflac_android/services/download_motion_artwork_source.dart'; import 'package:spotiflac_android/services/ffmpeg_service.dart'; +import 'package:spotiflac_android/services/hires_check_service.dart'; import 'package:spotiflac_android/services/replaygain_service.dart'; import 'package:spotiflac_android/services/notification_service.dart'; import 'package:spotiflac_android/services/verification_notification.dart'; @@ -57,6 +58,7 @@ part 'download_queue_provider_finalization.dart'; part 'download_queue_provider_replaygain.dart'; part 'download_queue_provider_embedding.dart'; part 'download_queue_provider_single_item.dart'; +part 'download_queue_provider_hires_check.dart'; final _log = AppLogger('DownloadQueue'); diff --git a/lib/providers/download_queue_provider_hires_check.dart b/lib/providers/download_queue_provider_hires_check.dart new file mode 100644 index 00000000..551223b9 --- /dev/null +++ b/lib/providers/download_queue_provider_hires_check.dart @@ -0,0 +1,224 @@ +part of 'download_queue_provider.dart'; + +// Per-track fake Hi-Res check (AppSettings.redownloadFakeHiRes), ported from +// SpotiFLAC-Module-Version's --redownload-fake-hires. +// +// Runs inside one _DownloadRun, right after the file is decoded and before +// anything is published, tagged or converted: the verdict decides which file +// the rest of the pipeline works on. A flagged file is set aside, the same +// track is downloaded again at LOSSLESS, and only then is the flagged file +// deleted. If the replacement fails the original is put back unchanged. + +enum _FakeHiResOutcome { + /// Nothing to do, or the replacement failed: finish with the current file. + kept, + + /// `result` now holds the LOSSLESS replacement; finalize that instead. + replaced, + + /// The item was cancelled or paused mid-replacement; stop the run. + aborted, +} + +/// Everything a replacement attempt may overwrite, so a failed one can put +/// the run back exactly as it was. +class _FakeHiResRunState { + _FakeHiResRunState(_DownloadRun run) + : result = run.result, + quality = run.quality, + safOutputExt = run.safOutputExt, + safFileName = run.safFileName, + safBaseName = run.safBaseName, + finalSafFileName = run.finalSafFileName, + effectiveSafMode = run.effectiveSafMode, + effectiveOutputDir = run.effectiveOutputDir, + trackToDownload = run.trackToDownload; + + final Map result; + final String quality; + final String safOutputExt; + final String? safFileName; + final String? safBaseName; + final String? finalSafFileName; + final bool effectiveSafMode; + final String effectiveOutputDir; + final Track trackToDownload; + + void restore(_DownloadRun run) { + run.result = result; + run.quality = quality; + run.safOutputExt = safOutputExt; + run.safFileName = safFileName; + run.safBaseName = safBaseName; + run.finalSafFileName = finalSafFileName; + run.effectiveSafMode = effectiveSafMode; + run.effectiveOutputDir = effectiveOutputDir; + run.trackToDownload = trackToDownload; + } +} + +extension _DownloadRunFakeHiRes on _DownloadRun { + /// [requestTrack] is the track as it was sent to the backend, before the + /// first result was merged into it: the replacement gets the same brief + /// the first attempt did, not the first attempt's conclusions. + Future<_FakeHiResOutcome> _replaceFakeHiResIfNeeded( + Track requestTrack, + ) async { + final path = filePath; + if (!settings.redownloadFakeHiRes || + wasExisting || + path == null || + !HiResCheckService.isHiResQuality(quality)) { + return _FakeHiResOutcome.kept; + } + if (isContentUri(path)) { + // Already published to SAF: there is no local file to set aside, and + // a heuristic is not worth deleting the only copy for. + _log.d('[hires-check] skipped: output already published to SAF'); + return _FakeHiResOutcome.kept; + } + if (!_serviceOffersFakeHiResFallback()) { + _log.d( + '[hires-check] skipped: ${item.service} has no ' + '${HiResCheckService.fakeHiResFallbackQuality} quality', + ); + return _FakeHiResOutcome.kept; + } + + final HiResCheckResult check; + try { + check = await HiResCheckService.check(path); + } catch (e) { + // Never a reason to fail a finished download. + _log.w('[hires-check] skipped for ${item.track.name}: $e'); + return _FakeHiResOutcome.kept; + } + if (!check.supported || !check.isFake) { + _log.d( + '[hires-check] ${item.track.name}: ' + '${check.supported ? check.verdict : 'unsupported format'}', + ); + return _FakeHiResOutcome.kept; + } + if (!check.redownloadSafe) { + // A suspect may be a genuine master low-pass filtered in mastering; + // a LOSSLESS copy could lose bit depth it really has. Report only. + _log.w( + '[hires-check] ${item.track.name} may be fake Hi-Res ' + '(${check.reason}); kept, not certain enough to replace', + ); + return _FakeHiResOutcome.kept; + } + + _log.w( + '[hires-check] ${item.track.name} looks like fake Hi-Res ' + '(${check.reason}); re-downloading as ' + '${HiResCheckService.fakeHiResFallbackQuality}', + ); + + final quarantined = '$path${HiResCheckService.quarantineSuffix}'; + try { + await File(path).rename(quarantined); + } catch (e) { + _log.w('[hires-check] could not set aside $path, keeping it: $e'); + return _FakeHiResOutcome.kept; + } + + final saved = _FakeHiResRunState(this); + var keepQuarantine = false; + try { + trackToDownload = requestTrack; + await _useFakeHiResFallbackQuality(); + n.updateItemStatus(item.id, DownloadStatus.downloading, progress: 0); + + if (!await _downloadAndMaybeFallback()) { + await deleteFile(quarantined); + return _FakeHiResOutcome.aborted; + } + final replacementPath = result['file_path'] as String?; + if (await _shouldAbort( + 'during fake Hi-Res re-download', + deleteFileOnAbort: result['success'] == true ? replacementPath : null, + )) { + await deleteFile(quarantined); + return _FakeHiResOutcome.aborted; + } + + final replaced = + result['success'] == true && + result['already_exists'] != true && + normalizeOptionalString(replacementPath) != null; + if (!replaced) { + _log.w( + '[hires-check] ${HiResCheckService.fakeHiResFallbackQuality} ' + 're-download of ${item.track.name} failed ' + '(${result['error'] ?? 'no file'}); keeping the flagged file', + ); + keepQuarantine = true; + return _FakeHiResOutcome.kept; + } + + await deleteFile(quarantined); + // Mirrors _run: the SAF folder is resolved per result. + if (effectiveSafMode && result['saf_relative_dir'] is String) { + effectiveOutputDir = n._sanitizeSafRelativeDir( + result['saf_relative_dir'] as String, + ); + } + _log.i( + '[hires-check] replaced fake Hi-Res ${item.track.name} with ' + '$replacementPath', + ); + return _FakeHiResOutcome.replaced; + } catch (e) { + _log.w('[hires-check] re-download of ${item.track.name} failed: $e'); + keepQuarantine = true; + return _FakeHiResOutcome.kept; + } finally { + if (keepQuarantine) { + saved.restore(this); + try { + await File(quarantined).rename(path); + } catch (e) { + _log.e('[hires-check] could not restore $path from $quarantined: $e'); + } + } + } + } + + /// Only LOSSLESS is a meaningful replacement. A provider that does not + /// list it would silently fall back to its default, which may be the very + /// Hi-Res tier that produced the fake. + bool _serviceOffersFakeHiResFallback() { + final service = item.service.trim().toLowerCase(); + final extension = extensionState.extensions + .where((e) => e.id.toLowerCase() == service) + .firstOrNull; + final options = extension?.qualityOptions ?? const []; + return options.isEmpty || + options.any( + (o) => + o.id.toUpperCase() == HiResCheckService.fakeHiResFallbackQuality, + ); + } + + /// Points the next backend request at LOSSLESS. The output folder stays + /// as resolved (including any storage fallback); only what depends on the + /// quality is rebuilt. + Future _useFakeHiResFallbackQuality() async { + quality = HiResCheckService.fakeHiResFallbackQuality; + safOutputExt = n._determineOutputExt(quality, item.service); + if (effectiveSafMode) { + final name = await n._buildSafFileNameForItem( + item, + trackToDownload, + filenameFormat: effectiveFilenameFormat, + quality: quality, + outputExt: safOutputExt, + ); + safFileName = name; + safBaseName = name.replaceFirst(RegExp(r'\.[^.]+$'), ''); + finalSafFileName = name; + } + } +} diff --git a/lib/providers/download_queue_provider_native_worker.dart b/lib/providers/download_queue_provider_native_worker.dart index 5fb53398..13b9ad9b 100644 --- a/lib/providers/download_queue_provider_native_worker.dart +++ b/lib/providers/download_queue_provider_native_worker.dart @@ -869,6 +869,14 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier { var quality = item.qualityOverride ?? state.audioQuality; if (quality == 'DEFAULT') quality = state.audioQuality; + // The fake Hi-Res check and its LOSSLESS re-download live in the Dart + // run (_DownloadRun._replaceFakeHiResIfNeeded); the native finalizer has + // no equivalent, so these items stay on the Dart queue. + if (settings.redownloadFakeHiRes && + HiResCheckService.isHiResQuality(quality)) { + return null; + } + final isSafMode = _isSafMode(settings); final rawOutputDir = isSafMode ? _buildRelativeOutputDir( diff --git a/lib/providers/download_queue_provider_single_item.dart b/lib/providers/download_queue_provider_single_item.dart index 2a26420f..29425b72 100644 --- a/lib/providers/download_queue_provider_single_item.dart +++ b/lib/providers/download_queue_provider_single_item.dart @@ -132,6 +132,7 @@ class _DownloadRun { Map? probedFinalMetadata; bool externalLrcWritten = false; final Map _directoryScopes = {}; + bool fakeHiResChecked = false; Future _run() async { final normalizedService = n._normalizeQueuedService(item.service); @@ -729,6 +730,7 @@ class _DownloadRun { decryptionDescriptor = DownloadDecryptionDescriptor.fromDownloadResult( result, ); + final requestTrack = trackToDownload; trackToDownload = buildTrackForMetadataEmbedding( trackToDownload, result, @@ -748,6 +750,19 @@ class _DownloadRun { return false; } + // At most once per run: the LOSSLESS replacement is not checked again. + if (!fakeHiResChecked) { + fakeHiResChecked = true; + switch (await _replaceFakeHiResIfNeeded(requestTrack)) { + case _FakeHiResOutcome.replaced: + return _handleDownloadSuccess(); + case _FakeHiResOutcome.aborted: + return false; + case _FakeHiResOutcome.kept: + break; + } + } + final deferredSafPublish = effectiveSafMode && result['saf_deferred_publish'] == true && diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 23c95c5c..99cc2fa7 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -792,6 +792,11 @@ class SettingsNotifier extends Notifier { _saveSettings(); } + void setRedownloadFakeHiRes(bool enabled) { + state = state.copyWith(redownloadFakeHiRes: enabled); + _saveSettings(); + } + void setAutoConvertFormat(String format) { state = state.copyWith( autoConvertFormat: normalizeAutoConvertFormat(format), diff --git a/lib/screens/settings/download_settings_page.dart b/lib/screens/settings/download_settings_page.dart index 3e384e77..a29e7304 100644 --- a/lib/screens/settings/download_settings_page.dart +++ b/lib/screens/settings/download_settings_page.dart @@ -107,6 +107,15 @@ class _DownloadSettingsPageState extends ConsumerState { showDivider: true, ), ], + SettingsSwitchItem( + icon: Icons.verified_outlined, + title: context.l10n.downloadRedownloadFakeHiRes, + subtitle: context.l10n.downloadRedownloadFakeHiResSubtitle, + value: settings.redownloadFakeHiRes, + onChanged: (value) => ref + .read(settingsProvider.notifier) + .setRedownloadFakeHiRes(value), + ), SettingsSwitchItem( icon: Icons.auto_fix_high_outlined, title: context.l10n.downloadAutoConvert, diff --git a/lib/screens/settings/settings_search_catalog.dart b/lib/screens/settings/settings_search_catalog.dart index 4348505e..8cfcfb99 100644 --- a/lib/screens/settings/settings_search_catalog.dart +++ b/lib/screens/settings/settings_search_catalog.dart @@ -330,6 +330,12 @@ class SettingsSearchCatalog { title: l10n.downloadLossyFormat, keywords: const ['mp3', 'aac', 'opus'], ), + SettingsSearchEntry( + icon: Icons.verified_outlined, + title: l10n.downloadRedownloadFakeHiRes, + subtitle: l10n.downloadRedownloadFakeHiResSubtitle, + keywords: const ['hi-res', 'hires', 'fake', 'upsampled', '24-bit'], + ), SettingsSearchEntry( icon: Icons.auto_fix_high_outlined, title: l10n.downloadAutoConvert, diff --git a/lib/screens/track_metadata_cards.dart b/lib/screens/track_metadata_cards.dart index 71b6fd12..6897d8de 100644 --- a/lib/screens/track_metadata_cards.dart +++ b/lib/screens/track_metadata_cards.dart @@ -88,6 +88,16 @@ extension _TrackMetadataCards on _TrackMetadataScreenState { filePath: _filePath, codecHint: _storedAudioFormat, ), + if (HiResCheckCard.isCandidate( + _filePath, + _storedAudioFormat, + )) ...[ + const SizedBox(height: 16), + HiResCheckCard( + filePath: _filePath, + formatHint: _storedAudioFormat, + ), + ], ], if (!context.isMornye) ...[ diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 3245e635..1b1b54ce 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -64,6 +64,7 @@ import 'package:spotiflac_android/widgets/mornye_context_menu.dart'; import 'package:spotiflac_android/widgets/mornye_metadata_row.dart'; import 'package:spotiflac_android/widgets/player_artwork.dart'; import 'package:spotiflac_android/widgets/audio_analysis_widget.dart'; +import 'package:spotiflac_android/widgets/hires_check_card.dart'; import 'package:spotiflac_android/widgets/audio_quality_badges.dart'; import 'package:spotiflac_android/widgets/batch_convert_sheet.dart'; import 'package:spotiflac_android/widgets/cached_cover_image.dart'; diff --git a/lib/services/hires_check_service.dart b/lib/services/hires_check_service.dart new file mode 100644 index 00000000..96d03552 --- /dev/null +++ b/lib/services/hires_check_service.dart @@ -0,0 +1,109 @@ +import 'package:spotiflac_android/services/platform_bridge.dart'; + +/// Outcome of the Go fake Hi-Res check (go_backend/hires_check.go). +class HiResCheckResult { + final bool supported; + final String verdict; + final int declaredSampleRate; + final double cutoffFrequencyHz; + final int declaredBitDepth; + final int effectiveBitDepth; + final bool paddedBitDepth; + final String reason; + + /// For a fake: 'certain', 'likely' or 'suspect' (may be a genuine master + /// low-pass filtered in mastering). Empty otherwise. + final String confidence; + + /// 'sample_hold', 'linear_interpolation', 'imaging', or empty. + final String upsamplingArtifact; + + /// True only when a LOSSLESS copy would lose nothing this file holds. + final bool redownloadSafe; + + /// Highest frequency whose level still moves with the music; 0 when not + /// measured. + final double musicCutoffHz; + + /// True when the content past [musicCutoffHz] is steady noise (a DSD or + /// analog tape transfer), so [cutoffFrequencyHz] is where noise ends. + final bool ultrasonicNoiseOnly; + + /// With [ultrasonicNoiseOnly]: the lowest standard rate that holds all + /// the music. Otherwise the declared rate. + final int usefulSampleRate; + + const HiResCheckResult({ + required this.supported, + this.verdict = '', + this.declaredSampleRate = 0, + this.cutoffFrequencyHz = 0, + this.declaredBitDepth = 0, + this.effectiveBitDepth = 0, + this.paddedBitDepth = false, + this.reason = '', + this.confidence = '', + this.upsamplingArtifact = '', + this.redownloadSafe = false, + this.musicCutoffHz = 0, + this.ultrasonicNoiseOnly = false, + this.usefulSampleRate = 0, + }); + + factory HiResCheckResult.fromJson(Map json) { + return HiResCheckResult( + supported: json['supported'] == true, + verdict: json['verdict'] as String? ?? '', + declaredSampleRate: (json['declared_sample_rate'] as num?)?.toInt() ?? 0, + cutoffFrequencyHz: (json['cutoff_frequency_hz'] as num?)?.toDouble() ?? 0, + declaredBitDepth: (json['declared_bit_depth'] as num?)?.toInt() ?? 0, + effectiveBitDepth: (json['effective_bit_depth'] as num?)?.toInt() ?? 0, + paddedBitDepth: json['padded_bit_depth'] == true, + reason: json['reason'] as String? ?? '', + confidence: json['confidence'] as String? ?? '', + upsamplingArtifact: json['upsampling_artifact'] as String? ?? '', + redownloadSafe: json['redownload_safe'] == true, + musicCutoffHz: (json['music_cutoff_hz'] as num?)?.toDouble() ?? 0, + ultrasonicNoiseOnly: json['ultrasonic_noise_only'] == true, + usefulSampleRate: (json['useful_sample_rate'] as num?)?.toInt() ?? 0, + ); + } + + bool get isFake => verdict == 'fake_hires'; + + bool get isCertain => isFake && confidence == 'certain'; + bool get isSuspect => isFake && confidence == 'suspect'; + + /// Mirrors the Go thresholds: a rate claim above 48 kHz whose content + /// stops below 28 kHz. Recomputed here so the reason can be localized. + bool get upsampled => + isFake && declaredSampleRate > 48000 && cutoffFrequencyHz < 28000; +} + +class HiResCheckService { + /// The requested qualities that actually claim Hi-Res. A LOSSLESS request + /// answered with CD-range audio is not a fake: it is what was asked for. + static const hiResQualities = {'HI_RES', 'HI_RES_LOSSLESS'}; + + /// What a flagged download is replaced with. An upsampled 24/96 file holds + /// no more information than the CD master it was made from, so this is the + /// same audio, honestly labelled and smaller. + static const fakeHiResFallbackQuality = 'LOSSLESS'; + + /// Suffix of the flagged file while its replacement downloads. It is only + /// deleted once the replacement exists: a heuristic is not a good enough + /// reason to leave the user with no copy of the track at all. + static const quarantineSuffix = '.fake-hires.bak'; + + static bool isHiResQuality(String quality) => + hiResQualities.contains(quality.trim().toUpperCase()); + + /// Runs the check; throws on failure. `supported == false` for formats + /// the Go checker cannot decode (anything but FLAC and PCM WAV). + static Future check(String filePath) async { + final json = await PlatformBridge.checkHiResAuthenticity(filePath); + final error = json['error']; + if (error != null) throw StateError(error.toString()); + return HiResCheckResult.fromJson(json); + } +} diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 9ef33af3..ef3b2a75 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -1156,6 +1156,18 @@ class PlatformBridge { }); } + /// Runs the Go fake Hi-Res check (spectral cutoff + padded bit depth) on + /// a FLAC or WAV file. Returns `{"supported": false}` for other formats. + static Future> checkHiResAuthenticity( + String filePath, { + Map? options, + }) { + return _invokeMap('checkHiResAuthenticity', { + 'file_path': filePath, + 'options_json': options == null ? '' : jsonEncode(options), + }); + } + /// Reads the tags and quality fields used for automatic Library display. /// /// Android's readAudioMetadata implementation can inspect most SAF files diff --git a/lib/widgets/hires_check_card.dart b/lib/widgets/hires_check_card.dart new file mode 100644 index 00000000..20d27c67 --- /dev/null +++ b/lib/widgets/hires_check_card.dart @@ -0,0 +1,308 @@ +import 'package:flutter/material.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/services/hires_check_service.dart'; +import 'package:spotiflac_android/widgets/settings_group.dart'; + +/// On-demand fake Hi-Res check for one FLAC/WAV file: spectral cutoff for +/// upsampling, and unused low bits for a 16-bit master padded to 24. +class HiResCheckCard extends StatefulWidget { + final String filePath; + final String? formatHint; + + const HiResCheckCard({super.key, required this.filePath, this.formatHint}); + + /// Only FLAC and PCM WAV are decoded by the Go checker. + static bool isCandidate(String filePath, String? formatHint) { + final format = formatHint?.toLowerCase().trim() ?? ''; + if (format == 'flac' || format == 'wav') return true; + final lower = filePath.toLowerCase(); + return lower.endsWith('.flac') || lower.endsWith('.wav'); + } + + @override + State createState() => _HiResCheckCardState(); +} + +class _HiResCheckCardState extends State { + HiResCheckResult? _result; + bool _checking = false; + String? _error; + int _requestId = 0; + + @override + void didUpdateWidget(covariant HiResCheckCard oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.filePath != widget.filePath) { + _requestId++; + _result = null; + _error = null; + _checking = false; + } + } + + Future _check() async { + final requestId = ++_requestId; + setState(() { + _checking = true; + _error = null; + }); + String? error; + HiResCheckResult? result; + try { + result = await HiResCheckService.check(widget.filePath); + } on StateError catch (e) { + error = e.message; + } catch (e) { + error = e.toString(); + } + if (!mounted || requestId != _requestId) return; + setState(() { + _checking = false; + _result = result; + _error = error; + }); + } + + @override + Widget build(BuildContext context) { + if (!HiResCheckCard.isCandidate(widget.filePath, widget.formatHint)) { + return const SizedBox.shrink(); + } + final cs = Theme.of(context).colorScheme; + final l10n = context.l10n; + final result = _result; + + return Card( + elevation: 0, + color: settingsGroupColor(context), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + side: BorderSide(color: cs.outlineVariant.withValues(alpha: 0.5)), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.verified_outlined, color: cs.primary, size: 20), + const SizedBox(width: 8), + Expanded( + child: Text( + l10n.hiResCheckTitle, + style: TextStyle( + color: cs.onSurface, + fontWeight: FontWeight.w600, + fontSize: 14, + ), + ), + ), + if (_checking) + const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2.5), + ) + else if (result != null || _error != null) + IconButton( + icon: const Icon(Icons.refresh, size: 20), + tooltip: l10n.audioAnalysisRescan, + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 48, + minHeight: 48, + ), + color: cs.onSurfaceVariant, + onPressed: _check, + ), + ], + ), + const SizedBox(height: 8), + if (_checking) + _secondaryText(l10n.hiResCheckChecking, cs) + else if (_error != null) + Text( + l10n.hiResCheckFailed(_error!), + style: TextStyle(color: cs.error, fontSize: 13), + ) + else if (result == null) ...[ + _secondaryText(l10n.hiResCheckDescription, cs), + const SizedBox(height: 12), + FilledButton.tonalIcon( + onPressed: _check, + icon: const Icon(Icons.search, size: 18), + label: Text(l10n.hiResCheckRun), + ), + ] else if (!result.supported) + _secondaryText(l10n.hiResCheckUnsupported, cs) + else + ..._buildResult(context, result, cs), + ], + ), + ), + ); + } + + Widget _secondaryText(String text, ColorScheme cs) { + return Text( + text, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ); + } + + List _buildResult( + BuildContext context, + HiResCheckResult result, + ColorScheme cs, + ) { + final l10n = context.l10n; + final (IconData icon, Color color, String label) = switch (result.verdict) { + 'fake_hires' when result.isCertain => ( + Icons.error_outline, + cs.error, + l10n.hiResCheckVerdictFakeCertain, + ), + 'fake_hires' when result.isSuspect => ( + Icons.help_outline, + cs.tertiary, + l10n.hiResCheckVerdictFakeSuspect, + ), + 'fake_hires' => ( + Icons.warning_amber_rounded, + cs.error, + l10n.hiResCheckVerdictFake, + ), + 'genuine_hires' => ( + Icons.check_circle_outline, + cs.primary, + l10n.hiResCheckVerdictGenuine, + ), + 'standard_definition' => ( + Icons.info_outline, + cs.onSurfaceVariant, + l10n.hiResCheckVerdictStandard, + ), + _ => ( + Icons.help_outline, + cs.onSurfaceVariant, + l10n.hiResCheckVerdictInconclusive, + ), + }; + + final effectiveBits = result.effectiveBitDepth > 0 + ? result.effectiveBitDepth + : null; + final reasons = [ + if (result.upsamplingArtifact == 'sample_hold') + l10n.hiResCheckReasonSampleHold, + if (result.upsamplingArtifact == 'linear_interpolation') + l10n.hiResCheckReasonInterpolation, + if (result.upsamplingArtifact == 'imaging') l10n.hiResCheckReasonImaging, + if (result.upsampled) + l10n.hiResCheckReasonRate( + _formatKHz(result.declaredSampleRate.toDouble()), + _formatKHz(result.cutoffFrequencyHz), + ), + if (result.isFake && result.paddedBitDepth) + l10n.hiResCheckReasonDepth( + result.declaredBitDepth, + result.effectiveBitDepth, + ), + ]; + + return [ + Row( + children: [ + Icon(icon, color: color, size: 18), + const SizedBox(width: 6), + Expanded( + child: Text( + label, + style: TextStyle( + color: color, + fontWeight: FontWeight.w600, + fontSize: 13, + ), + ), + ), + ], + ), + for (final reason in reasons) ...[ + const SizedBox(height: 4), + _secondaryText('• $reason', cs), + ], + if (result.ultrasonicNoiseOnly) ...[ + const SizedBox(height: 4), + _secondaryText( + '• ${l10n.hiResCheckUltrasonicNoise(_formatKHz(result.musicCutoffHz), _formatKHz(result.usefulSampleRate.toDouble()))}', + cs, + ), + ], + if (result.verdict != 'inconclusive') ...[ + const SizedBox(height: 8), + Wrap( + spacing: 16, + runSpacing: 4, + children: [ + // Past the music only steady noise remains, so where it ends + // says nothing about the music: show the music's own edge. + if (result.ultrasonicNoiseOnly) + _detail( + l10n.hiResCheckMusicContent, + '~${_formatKHz(result.musicCutoffHz)}', + cs, + ) + else + _detail( + l10n.hiResCheckCutoff, + '~${_formatKHz(result.cutoffFrequencyHz)}', + cs, + ), + if (result.declaredBitDepth > 0) + _detail( + l10n.hiResCheckBitsInUse, + '${effectiveBits ?? '?'} / ${result.declaredBitDepth}-bit', + cs, + ), + ], + ), + ], + if (result.isSuspect) ...[ + const SizedBox(height: 8), + Text( + l10n.hiResCheckDisclaimer, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 11, + fontStyle: FontStyle.italic, + ), + ), + ], + ]; + } + + Widget _detail(String label, String value, ColorScheme cs) { + return Text.rich( + TextSpan( + children: [ + TextSpan( + text: '$label: ', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12), + ), + TextSpan( + text: value, + style: TextStyle( + color: cs.onSurface, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ); + } + + String _formatKHz(double hz) => '${(hz / 1000).toStringAsFixed(1)} kHz'; +} diff --git a/rust_backend/Cargo.lock b/rust_backend/Cargo.lock index 9c073afc..63883051 100644 --- a/rust_backend/Cargo.lock +++ b/rust_backend/Cargo.lock @@ -474,6 +474,12 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" +[[package]] +name = "claxon" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bfbf56724aa9eca8afa4fcfadeb479e722935bb2a0900c2d37e0cc477af0688" + [[package]] name = "cmov" version = "0.5.4" @@ -2366,6 +2372,7 @@ version = "0.1.0" dependencies = [ "base64", "chrono", + "claxon", "image", "regex", "rustix", diff --git a/rust_backend/Cargo.toml b/rust_backend/Cargo.toml index 46b22362..dd5be2be 100644 --- a/rust_backend/Cargo.toml +++ b/rust_backend/Cargo.toml @@ -58,6 +58,8 @@ html5ever = "=0.39.0" unicode-normalization = "=0.1.22" unicode-general-category = "=0.6.0" time = { version = "=0.3.55", features = ["parsing"] } +# Pure-Rust FLAC decoder for the Hi-Res authenticity check. +claxon = "=0.4.3" chrono = { version = "=0.4.45", default-features = false, features = ["clock"] } tz-rs = "=0.7.3" httpdate = "=1.0.3" diff --git a/rust_backend/crates/core/Cargo.toml b/rust_backend/crates/core/Cargo.toml index 6d38a356..78d3a211 100644 --- a/rust_backend/crates/core/Cargo.toml +++ b/rust_backend/crates/core/Cargo.toml @@ -16,6 +16,7 @@ unicode-normalization.workspace = true unicode-general-category.workspace = true chrono.workspace = true rustix.workspace = true +claxon.workspace = true [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] tz-rs.workspace = true diff --git a/rust_backend/crates/core/src/media/hires/evidence.rs b/rust_backend/crates/core/src/media/hires/evidence.rs new file mode 100644 index 00000000..32242a1b --- /dev/null +++ b/rust_backend/crates/core/src/media/hires/evidence.rs @@ -0,0 +1,491 @@ +//! Evidence that separates a fake Hi-Res file from a genuine master that was +//! merely low-pass filtered. Above ~22 kHz the two are the same signal, so no +//! spectral rule can tell them apart everywhere; these tests answer instead +//! how the file was made and whether a LOSSLESS copy would lose anything. +//! +//! - Integer-ratio upsampling artifacts (sample-and-hold, linear +//! interpolation) and spectral imaging are exact fingerprints: "certain". +//! - A brickwall right at a CD/DAT Nyquist (22.05 / 24 kHz) means a +//! 44.1/48 kHz chain. With an in-band noise floor no lower than 16-bit +//! quantization noise, nothing in the file exceeds what a 16-bit LOSSLESS +//! copy holds: "likely", safe to replace. +//! - Anything else that fails the cutoff test may be a genuine master: +//! "suspect", never replaced automatically. + +use super::fft::Radix2Fft; + +pub const CONFIDENCE_CERTAIN: &str = "certain"; +pub const CONFIDENCE_LIKELY: &str = "likely"; +pub const CONFIDENCE_SUSPECT: &str = "suspect"; + +pub const ARTIFACT_SAMPLE_HOLD: &str = "sample_hold"; +pub const ARTIFACT_INTERPOLATION: &str = "linear_interpolation"; +pub const ARTIFACT_IMAGING: &str = "imaging"; + +pub const FLOOR_BELOW_16BIT: &str = "below_16bit"; +pub const FLOOR_AT_16BIT: &str = "at_16bit"; +pub const FLOOR_MASKED: &str = "masked"; + +/// Standard-definition rates a fake is made from; their Nyquist is where a +/// resampler's anti-imaging filter leaves its cliff. +const SOURCE_RATES: [u32; 2] = [44_100, 48_000]; + +/// Band the in-band noise floor is measured over. Noise-shaped dither lowers +/// the floor in the mid-band only by raising it above ~15 kHz, so averaging +/// over the whole band keeps a shaped 16-bit floor at or above the flat one. +const FLOOR_BAND_LOW_HZ: f64 = 500.0; +const FLOOR_BAND_HIGH_HZ: f64 = 20_000.0; +/// Fraction of fully-inside STFT frames, quietest first, the floor is read in. +const QUIET_FRAME_FRACTION: f64 = 0.1; +/// Floor against flat 16-bit quantization noise: below this there is +/// resolution a 16-bit copy would lose; above FLOOR_MASKED_DB the quietest +/// frames still carry music and the floor cannot be read. +const FLOOR_BELOW_16BIT_DB: f64 = -6.0; +const FLOOR_MASKED_DB: f64 = 20.0; +/// Brickwall: flat passband before the source Nyquist, a cliff after it. +const BRICKWALL_MAX_PASSBAND_DROP_DB: f64 = 20.0; +const BRICKWALL_MIN_CLIFF_DB: f64 = 40.0; +/// Within this, the passband counts as still flat at the edge. +const BRICKWALL_FLAT_PASSBAND_DB: f64 = 6.0; +/// Imaging: the band above the source Nyquist mirrors the one below. +const IMAGING_MIN_CORRELATION: f64 = 0.9; +/// Integer-upsampling artifacts: the signal must move at enough original +/// samples, and (almost) never between them. +const ARTIFACT_MIN_MOVING_ANCHORS: usize = 1000; +const ARTIFACT_MAX_VIOLATION_RATE: f64 = 0.001; +/// Music bandwidth: 1 kHz bands from MUSIC_BAND_START_HZ up are music while +/// their level swings with it across frames (p95-p5 at least +/// MUSIC_MIN_SPREAD_DB). Steady noise, such as the ultrasonic hump a DSD or +/// tape transfer carries up to Nyquist, averages out to a few dB. +const MUSIC_BAND_START_HZ: f64 = 16_000.0; +const MUSIC_BAND_WIDTH_HZ: f64 = 1_000.0; +const MUSIC_MIN_SPREAD_DB: f64 = 10.0; +/// Active content this far past the music bandwidth is reported as steady +/// noise rather than content. +pub const ULTRASONIC_NOISE_MARGIN_HZ: f64 = 8_000.0; +/// Standard rate families; a file's useful rate is looked up in its own. +const RATE_FAMILIES: [[u32; 4]; 2] = [ + [44_100, 88_200, 176_400, 352_800], + [48_000, 96_000, 192_000, 384_000], +]; + +/// The averaged spectrum plus what the quiet frames and the ultrasonic bands +/// say, gathered in the same STFT pass. +pub struct StftStats { + pub avg_magnitude: Vec, + /// Variance-equivalent (white noise) floor of the quietest frames in + /// full-scale units, or NaN when no frame qualified. + pub quiet_floor_var: f64, + /// p95-p5 spread across frames of each 1 kHz band's level, from 16 kHz + /// up, in order; see [`music_cutoff`]. + pub music_band_spreads: Vec, +} + +/// Matches `np.abs(librosa.stft(y, n_fft)).mean(axis=1)` for the averaged +/// spectrum: periodic Hann window, hop n_fft/4, frames centred by +/// zero-padding n_fft/2 at both ends. +pub fn analyze_stft( + y: &[f32], + n_fft: usize, + sample_rate: u32, + check: &dyn Fn() -> Result<(), String>, +) -> Result { + let hop = n_fft / 4; + let half = n_fft / 2; + let bins = half + 1; + let mut stats = StftStats { + avg_magnitude: vec![0.0; bins], + quiet_floor_var: f64::NAN, + music_band_spreads: Vec::new(), + }; + let padded_len = y.len() + 2 * half; + if padded_len < n_fft { + return Ok(stats); + } + let frame_count = 1 + (padded_len - n_fft) / hop; + + let window: Vec = (0..n_fft) + .map(|i| 0.5 - 0.5 * (2.0 * std::f64::consts::PI * i as f64 / n_fft as f64).cos()) + .collect(); + let window_power: f64 = window.iter().map(|w| w * w).sum(); + let bin_hz = f64::from(sample_rate) / n_fft as f64; + let band_low = (FLOOR_BAND_LOW_HZ / bin_hz).ceil() as usize; + let band_high = ((FLOOR_BAND_HIGH_HZ / bin_hz) as usize).min(half); + + // Per-frame level of each 1 kHz band above 16 kHz, for the music + // bandwidth. Only above a CD's Nyquist does the question arise. + let mut music_bands: Vec<(usize, usize)> = Vec::new(); + let nyquist = f64::from(sample_rate) / 2.0; + if nyquist > f64::from(SOURCE_RATES[1]) / 2.0 { + let mut lo = MUSIC_BAND_START_HZ; + while lo + MUSIC_BAND_WIDTH_HZ <= nyquist { + let first = (lo / bin_hz).ceil() as usize; + let last = (((lo + MUSIC_BAND_WIDTH_HZ) / bin_hz).ceil() as usize).min(half + 1); + if last > first { + music_bands.push((first, last)); + } + lo += MUSIC_BAND_WIDTH_HZ; + } + } + let mut music_levels: Vec> = vec![Vec::new(); music_bands.len()]; + + let fft = Radix2Fft::new(n_fft); + let mut re = vec![0.0; n_fft]; + let mut im = vec![0.0; n_fft]; + let mut band_power: Vec = Vec::with_capacity(band_high.saturating_sub(band_low) + 1); + // (mean, median) band power of each fully-inside, non-silent frame. + let mut frames: Vec<(f64, f64)> = Vec::new(); + + for f in 0..frame_count { + if f.is_multiple_of(256) { + check()?; + } + // Index into y of the frame's first sample. + let start = (f * hop) as isize - half as isize; + for (i, (r, w)) in re.iter_mut().zip(&window).enumerate() { + let j = start + i as isize; + *r = if j >= 0 && (j as usize) < y.len() { + f64::from(y[j as usize]) * w + } else { + 0.0 + }; + } + im.fill(0.0); + fft.transform(&mut re, &mut im); + for (k, avg) in stats.avg_magnitude.iter_mut().enumerate() { + *avg += re[k].hypot(im[k]); + } + + // Frames that overlap the zero padding would read as quiet for the + // wrong reason; only frames fully inside the signal are candidates. + if start < 0 || start as usize + n_fft > y.len() || band_high <= band_low { + continue; + } + band_power.clear(); + let mut sum = 0.0; + for k in band_low..=band_high { + let p = re[k] * re[k] + im[k] * im[k]; + band_power.push(p); + sum += p; + } + if sum == 0.0 { + continue; // digital silence carries no floor to measure + } + let mean = sum / band_power.len() as f64; + frames.push((mean, median_in_place(&mut band_power))); + for (levels, &(first, last)) in music_levels.iter_mut().zip(&music_bands) { + let power: f64 = (first..last) + .map(|k| re[k] * re[k] + im[k] * im[k]) + .sum::() + / (last - first) as f64; + levels.push(10.0 * power.max(1e-30).log10()); + } + } + for avg in &mut stats.avg_magnitude { + *avg /= frame_count as f64; + } + + if !frames.is_empty() { + frames.sort_by(|a, b| a.0.total_cmp(&b.0)); + let count = ((frames.len() as f64 * QUIET_FRAME_FRACTION) as usize).max(1); + // |X|^2 of white noise is exponential with mean sigma^2 * sum(w^2), so + // its median is ln 2 times that. The median ignores tonal peaks. + let mut estimates: Vec = frames[..count] + .iter() + .map(|&(_, median)| median / std::f64::consts::LN_2 / window_power) + .collect(); + stats.quiet_floor_var = median_in_place(&mut estimates); + } + for mut levels in music_levels { + if levels.len() < 2 { + break; + } + levels.sort_by(f64::total_cmp); + stats + .music_band_spreads + .push(percentile_sorted(&levels, 0.95) - percentile_sorted(&levels, 0.05)); + } + Ok(stats) +} + +/// Compares the measured floor with flat 16-bit quantization noise: LSB^2/12 +/// per channel, divided by the channel count for the mono downmix of +/// independent channels, which is the lowest it can be. +pub fn classify_noise_floor(floor_var: f64, channels: u32) -> (&'static str, f64) { + if floor_var.is_nan() || floor_var <= 0.0 { + return ("", 0.0); + } + let lsb = 2f64.powi(-15); + let reference = lsb * lsb / 12.0 / f64::from(channels.max(1)); + let vs_16bit_db = 10.0 * (floor_var / reference).log10(); + let class = if vs_16bit_db < FLOOR_BELOW_16BIT_DB { + FLOOR_BELOW_16BIT + } else if vs_16bit_db > FLOOR_MASKED_DB { + FLOOR_MASKED + } else { + FLOOR_AT_16BIT + }; + (class, vs_16bit_db) +} + +/// Converts the averaged magnitude spectrum to dB below its peak. +pub fn spectrum_db(avg: &[f64]) -> Vec { + let peak = avg.iter().copied().fold(0.0, f64::max).max(1e-30); + avg.iter() + .map(|&v| 20.0 * (v.max(1e-30) / peak).log10()) + .collect() +} + +/// The bins between `low_hz` and `high_hz` inclusive. +fn spectrum_band( + spec_db: &[f64], + sample_rate: u32, + n_fft: usize, + low_hz: f64, + high_hz: f64, +) -> Vec { + let bin_hz = f64::from(sample_rate) / n_fft as f64; + let lo = (low_hz / bin_hz).ceil().max(0.0) as usize; + let hi = ((high_hz / bin_hz) as usize).min(spec_db.len().saturating_sub(1)); + if hi < lo { + return Vec::new(); + } + spec_db[lo..=hi].to_vec() +} + +/// The source Nyquist (22050 or 24000 Hz) at which the spectrum stays flat +/// right up to the edge and then falls off a cliff: the shape a resampler's +/// anti-imaging filter leaves. A mastering low-pass rolls off gradually and is +/// already well down before the edge. 0 if neither. +/// +/// A 48 kHz source passes the test at 22.05 kHz too (its cliff lies beyond +/// that edge as well), so the highest edge the passband still reaches flat +/// wins; a 44.1 kHz source is already sloping into its transition band there. +pub fn detect_brickwall( + spec_db: &[f64], + sample_rate: u32, + n_fft: usize, + noise_floor_db: f64, +) -> f64 { + let mut best = 0.0; + let mut best_drop = f64::INFINITY; + for rate in SOURCE_RATES { + let edge = f64::from(rate) / 2.0; + if edge + 5000.0 > f64::from(sample_rate) / 2.0 { + continue; + } + let mut passband = spectrum_band(spec_db, sample_rate, n_fft, edge - 8000.0, edge - 5000.0); + let mut below = spectrum_band(spec_db, sample_rate, n_fft, edge - 2500.0, edge - 500.0); + let mut above = spectrum_band(spec_db, sample_rate, n_fft, edge + 2500.0, edge + 5000.0); + if passband.is_empty() || below.is_empty() || above.is_empty() { + continue; + } + let below_level = median_in_place(&mut below); + let drop = median_in_place(&mut passband) - below_level; + let cliff = below_level - median_in_place(&mut above); + if below_level <= noise_floor_db + || drop > BRICKWALL_MAX_PASSBAND_DROP_DB + || cliff < BRICKWALL_MIN_CLIFF_DB + { + continue; + } + let flat = drop <= BRICKWALL_FLAT_PASSBAND_DB; + let best_flat = best_drop <= BRICKWALL_FLAT_PASSBAND_DB; + if best == 0.0 + || (flat && (!best_flat || edge > best)) + || (!flat && !best_flat && drop < best_drop) + { + best = edge; + best_drop = drop; + } + } + best +} + +/// Whether the band above a source Nyquist mirrors the band below it, which +/// is what upsampling without (or with a poor) anti-imaging filter leaves. +/// Genuine content keeps falling with frequency, so its mirror correlation is +/// near zero or negative. +pub fn detect_imaging( + spec_db: &[f64], + sample_rate: u32, + n_fft: usize, + noise_floor_db: f64, +) -> bool { + let bin_hz = f64::from(sample_rate) / n_fft as f64; + for rate in SOURCE_RATES { + let rate = f64::from(rate); + let low_hz = rate / 2.0 + 500.0; + let high_hz = (rate - 500.0).min(f64::from(sample_rate) / 2.0 - 500.0); + if high_hz - low_hz < 2000.0 { + continue; + } + let mut image = Vec::new(); + let mut mirror = Vec::new(); + let mut k = (low_hz / bin_hz).ceil() as usize; + while k as f64 * bin_hz <= high_hz { + // Rounded half away from zero, as Go's math.Round does. + let m = ((rate - k as f64 * bin_hz) / bin_hz).round(); + if k < spec_db.len() && m >= 0.0 && (m as usize) < spec_db.len() { + image.push(spec_db[k]); + mirror.push(spec_db[m as usize]); + } + k += 1; + } + if image.len() < 16 || image.iter().sum::() / image.len() as f64 <= noise_floor_db { + continue; // no content above the edge: nothing was mirrored + } + if pearson(&image, &mirror) >= IMAGING_MIN_CORRELATION { + return true; + } + } + false +} + +/// The exact fingerprints of upsampling by an integer ratio from 44.1/48 kHz: +/// every sample repeated (sample-and-hold) or the in-between samples on a +/// straight line (linear interpolation). `samples` is one channel, +/// right-justified; `or_bits` is the OR over the whole window. +pub fn detect_integer_upsampling(samples: &[i32], or_bits: u32, sample_rate: u32) -> &'static str { + if or_bits == 0 || samples.len() < 4 { + return ""; + } + // Measure in units of the bits actually used, so a padded source's + // rounding stays within a couple of its own LSBs. + let unused = or_bits.trailing_zeros(); + for rate in SOURCE_RATES { + if !sample_rate.is_multiple_of(rate) { + continue; + } + let ratio = (sample_rate / rate) as usize; + if !(2..=8).contains(&ratio) { + continue; + } + for phase in 0..ratio { + let artifact = integer_upsampling_at_phase(samples, unused, ratio, phase); + if !artifact.is_empty() { + return artifact; + } + } + } + "" +} + +fn integer_upsampling_at_phase( + samples: &[i32], + unused: u32, + ratio: usize, + phase: usize, +) -> &'static str { + let at = |i: usize| i64::from(samples[i] >> unused); + let (mut inner, mut hold_violations, mut hold_moving) = (0usize, 0usize, 0usize); + let (mut line_violations, mut line_moving) = (0usize, 0usize); + for i in 1..samples.len() - 1 { + let d1 = at(i) - at(i - 1); + let d2 = (at(i - 1) - 2 * at(i) + at(i + 1)).abs(); + if !(i + ratio - phase).is_multiple_of(ratio) { + // Between two original samples. + inner += 1; + hold_violations += usize::from(d1 != 0); + line_violations += usize::from(d2 > 2); + } else { + // An original sample. + hold_moving += usize::from(d1 != 0); + line_moving += usize::from(d2 > 2); + } + } + let max_violations = inner as f64 * ARTIFACT_MAX_VIOLATION_RATE; + if hold_moving >= ARTIFACT_MIN_MOVING_ANCHORS && hold_violations as f64 <= max_violations { + return ARTIFACT_SAMPLE_HOLD; + } + if line_moving >= ARTIFACT_MIN_MOVING_ANCHORS && line_violations as f64 <= max_violations { + return ARTIFACT_INTERPOLATION; + } + "" +} + +/// Upper edge of the last contiguous 1 kHz band, from 16 kHz up, that both +/// carries active content (its level in the averaged spectrum above +/// `noise_floor_db`) and moves with the music. The level test keeps a +/// resampler's leakage, which swings with the music too but sits far below +/// it, from counting. 0 when even the first band fails. +pub fn music_cutoff( + spreads: &[f64], + spec_db: &[f64], + sample_rate: u32, + n_fft: usize, + noise_floor_db: f64, +) -> f64 { + let mut cutoff = 0.0; + for (index, &spread) in spreads.iter().enumerate() { + let lo = MUSIC_BAND_START_HZ + index as f64 * MUSIC_BAND_WIDTH_HZ; + let band = spectrum_band(spec_db, sample_rate, n_fft, lo, lo + MUSIC_BAND_WIDTH_HZ); + if band.is_empty() || spread < MUSIC_MIN_SPREAD_DB { + break; + } + if band.iter().sum::() / band.len() as f64 <= noise_floor_db { + break; + } + cutoff = lo + MUSIC_BAND_WIDTH_HZ; + } + cutoff +} + +/// The lowest standard rate in the declared rate's family whose Nyquist +/// still holds `music_cutoff_hz`; the declared rate itself when none below it +/// does, or when the rate is not a standard one. +pub fn useful_sample_rate(declared: u32, music_cutoff_hz: f64) -> u32 { + for family in RATE_FAMILIES { + if !declared.is_multiple_of(family[0]) { + continue; + } + for rate in family { + if rate >= declared { + break; + } + if f64::from(rate) / 2.0 >= music_cutoff_hz { + return rate; + } + } + } + declared +} + +fn pearson(a: &[f64], b: &[f64]) -> f64 { + let n = a.len() as f64; + let mean_a = a.iter().sum::() / n; + let mean_b = b.iter().sum::() / n; + let (mut cov, mut var_a, mut var_b) = (0.0, 0.0, 0.0); + for (&x, &y) in a.iter().zip(b) { + let (da, db) = (x - mean_a, y - mean_b); + cov += da * db; + var_a += da * da; + var_b += db * db; + } + if var_a == 0.0 || var_b == 0.0 { + return 0.0; + } + cov / (var_a * var_b).sqrt() +} + +/// Sorts `values` and returns their median. +pub(super) fn median_in_place(values: &mut [f64]) -> f64 { + if values.is_empty() { + return f64::NAN; + } + values.sort_by(f64::total_cmp); + let mid = values.len() / 2; + if values.len() % 2 == 1 { + values[mid] + } else { + (values[mid - 1] + values[mid]) / 2.0 + } +} + +/// numpy's default (linear) percentile of sorted values. +fn percentile_sorted(sorted: &[f64], q: f64) -> f64 { + let pos = q * (sorted.len() - 1) as f64; + let lo = pos.floor() as usize; + let hi = (lo + 1).min(sorted.len() - 1); + sorted[lo] + (sorted[hi] - sorted[lo]) * (pos - lo as f64) +} diff --git a/rust_backend/crates/core/src/media/hires/fft.rs b/rust_backend/crates/core/src/media/hires/fft.rs new file mode 100644 index 00000000..5bad8a9d --- /dev/null +++ b/rust_backend/crates/core/src/media/hires/fft.rs @@ -0,0 +1,53 @@ +//! In-place iterative Cooley-Tukey FFT with precomputed twiddles and +//! bit-reversal permutation, reused across all STFT frames. The same +//! algorithm as SpotiFLAC-Module-Version's checker, so both agree bin for bin. + +pub struct Radix2Fft { + n: usize, + twiddle_re: Vec, + twiddle_im: Vec, + reversed: Vec, +} + +impl Radix2Fft { + /// `n` must be a power of two. + pub fn new(n: usize) -> Self { + let angle = |k: usize| -2.0 * std::f64::consts::PI * k as f64 / n as f64; + let shift = usize::BITS - n.trailing_zeros(); + Self { + n, + twiddle_re: (0..n / 2).map(|k| angle(k).cos()).collect(), + twiddle_im: (0..n / 2).map(|k| angle(k).sin()).collect(), + reversed: (0..n) + .map(|i| i.reverse_bits().checked_shr(shift).unwrap_or(0)) + .collect(), + } + } + + pub fn transform(&self, re: &mut [f64], im: &mut [f64]) { + for (i, &j) in self.reversed.iter().enumerate() { + if i < j { + re.swap(i, j); + im.swap(i, j); + } + } + let mut size = 2; + while size <= self.n { + let half = size / 2; + let step = self.n / size; + for start in (0..self.n).step_by(size) { + for k in 0..half { + let (w_re, w_im) = (self.twiddle_re[k * step], self.twiddle_im[k * step]); + let (a, b) = (start + k, start + k + half); + let t_re = w_re * re[b] - w_im * im[b]; + let t_im = w_re * im[b] + w_im * re[b]; + re[b] = re[a] - t_re; + im[b] = im[a] - t_im; + re[a] += t_re; + im[a] += t_im; + } + } + size <<= 1; + } + } +} diff --git a/rust_backend/crates/core/src/media/hires/mod.rs b/rust_backend/crates/core/src/media/hires/mod.rs new file mode 100644 index 00000000..61503241 --- /dev/null +++ b/rust_backend/crates/core/src/media/hires/mod.rs @@ -0,0 +1,634 @@ +//! Hi-Res authenticity check, in lockstep with SpotiFLAC-Module-Version's +//! `core/hires_check.py`: same thresholds, same verdicts. +//! +//! A file can claim Hi-Res along two independent axes, and each is checked on +//! its own terms: +//! +//! - Sample rate: it declares e.g. 96 kHz but its spectral content stops at, +//! or just above, the ~22.05 kHz Nyquist of a 44.1 kHz source, the +//! fingerprint of upsampling. Measuring it is a heuristic. +//! - Bit depth: it declares 24-bit but only 16 of those bits ever carry data, +//! the low 8 being zero in every sample: a CD master padded out. Unlike the +//! spectral test this one is exact, and it is the only test that can judge +//! a 24-bit/44.1 kHz file. +//! +//! The cutoff alone cannot tell an upsampled CD from a genuine master that +//! was low-pass filtered in mastering, so every `fake_hires` verdict is graded +//! ("certain", "likely", "suspect") by the evidence in `evidence`. Only +//! certain/likely are safe to replace with a LOSSLESS copy. +//! +//! Only FLAC and PCM WAV are decoded. Anything else is +//! [`HiResCheckError::Unsupported`], which callers treat as "check skipped". + +mod evidence; +mod fft; +#[cfg(test)] +mod tests; + +use evidence::*; +use serde::{Deserialize, Serialize}; +use std::fs::File; +use std::io::{BufReader, Read, Seek, SeekFrom}; + +pub const VERDICT_FAKE: &str = "fake_hires"; +pub const VERDICT_STANDARD: &str = "standard_definition"; +pub const VERDICT_GENUINE: &str = "genuine_hires"; +pub const VERDICT_INCONCLUSIVE: &str = "inconclusive"; + +#[derive(Debug, PartialEq, Eq)] +pub enum HiResCheckError { + /// A container this checker cannot decode; never a reason to treat the + /// file itself as broken. + Unsupported, + Failed(String), +} + +impl std::fmt::Display for HiResCheckError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Unsupported => f.write_str("hi-res check: unsupported audio format"), + Self::Failed(message) => write!(f, "hi-res check: {message}"), + } + } +} + +impl From for HiResCheckError { + fn from(message: String) -> Self { + Self::Failed(message) + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(default)] +pub struct HiResCheckOptions { + /// Length of the segment analysed from the middle of the track. + pub sample_seconds: i64, + /// dB relative to the segment's spectral peak above which a bin is + /// considered active content rather than noise. + pub noise_floor_db: f64, + /// Sample rate above which a file claims Hi-Res. + pub hires_sample_rate_threshold: i64, + /// Minimum active-content cutoff a genuine Hi-Res file must reach. Sits + /// well above 22.05 kHz on purpose: a resampler upsampling from CD leaves + /// a transition-band tail a couple of kHz wide (a measured 44.1 -> 176.4 + /// kHz upsample reached ~24.7 kHz). + pub hires_cutoff_threshold_hz: f64, + /// STFT window size; must be a power of two. Shrunk for short segments. + pub n_fft: i64, +} + +impl Default for HiResCheckOptions { + fn default() -> Self { + Self { + sample_seconds: 30, + noise_floor_db: -80.0, + hires_sample_rate_threshold: 48_000, + hires_cutoff_threshold_hz: 28_000.0, + n_fft: 4096, + } + } +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize)] +pub struct HiResCheckResult { + pub file_path: String, + pub declared_sample_rate: u32, + pub total_duration_s: f64, + pub analyzed_duration_s: f64, + pub cutoff_frequency_hz: f64, + pub noise_floor_db: f64, + pub verdict: String, + /// Bits per sample the container declares; 0 when the format has no + /// fixed-point depth to declare (float PCM). + pub declared_bit_depth: u32, + /// Bits per sample that actually carry data; 0 when not measured. + pub effective_bit_depth: u32, + /// Why the file was flagged, in one clause; empty when it was not. + pub reason: String, + /// How sure a fake_hires verdict is: "certain" (exact fingerprint), + /// "likely" (a resampler's cliff over a 16-bit floor) or "suspect" (may be + /// a genuine low-pass filtered master). Empty for other verdicts. + pub confidence: String, + /// Exact upsampling fingerprint found, if any: "sample_hold", + /// "linear_interpolation" or "imaging". + pub upsampling_artifact: String, + /// Source Nyquist (22050 / 24000) with a resampler-style cliff; 0 if none. + pub brickwall_hz: f64, + /// In-band floor of the quietest frames against flat 16-bit quantization + /// noise: "below_16bit", "at_16bit", "masked" (music never quiet enough), + /// or empty when not measured. + pub noise_floor_class: String, + pub noise_floor_vs_16bit_db: f64, + /// Highest frequency whose level still moves with the music, for a file + /// claiming Hi-Res by rate; 0 when not measured. Informational: the + /// verdict rests on the tests above. + pub music_cutoff_hz: f64, + /// True when the active content past `music_cutoff_hz` is steady noise, + /// like the ultrasonic hump of a DSD or analog tape transfer, so + /// `cutoff_frequency_hz` marks where that noise ends rather than the music. + pub ultrasonic_noise_only: bool, + /// With `ultrasonic_noise_only`: the lowest standard rate of the same + /// family that holds all the music (e.g. 88200 for a 176.4 kHz file whose + /// music stops at 34 kHz). Otherwise the declared rate. + pub useful_sample_rate: u32, +} + +impl HiResCheckResult { + /// True for any "fake hi-res" verdict, whatever its confidence. + pub fn is_suspicious(&self) -> bool { + self.verdict == VERDICT_FAKE + } + + /// True when a LOSSLESS copy would lose nothing this file holds: a + /// certain fake, or a likely one. A suspect may be a genuine master. + pub fn redownload_safe(&self) -> bool { + self.is_suspicious() + && (self.confidence == CONFIDENCE_CERTAIN || self.confidence == CONFIDENCE_LIKELY) + } + + /// True when the declared depth is bits the file never uses. + pub fn padded_bit_depth(&self) -> bool { + self.declared_bit_depth > 16 + && self.effective_bit_depth > 0 + && self.effective_bit_depth <= 16 + } +} + +/// One decoded segment: the mono signal for the spectrum and the OR of every +/// raw sample for the bit-depth test, gathered in one pass. +#[derive(Default)] +struct Window { + mono: Vec, + /// OR of all raw integer samples, right-justified at the declared depth. + or_bits: u32, + /// First channel's raw integer samples, for the upsampling-artifact + /// tests; empty for float PCM. + first_channel: Vec, +} + +/// Analyses one file and returns a populated result. Only a short segment from +/// the middle of the track is decoded, never the whole file. +pub fn check_file( + mut file: File, + file_path: &str, + options: &HiResCheckOptions, + check: &dyn Fn() -> Result<(), String>, +) -> Result { + if options.sample_seconds <= 0 { + return Err("sample_seconds must be positive".to_string().into()); + } + if options.n_fft <= 0 || (options.n_fft & (options.n_fft - 1)) != 0 { + return Err("n_fft must be a positive power of two".to_string().into()); + } + let size = file.metadata().map_err(|e| e.to_string())?.len(); + if size == 0 { + return Err(format!("file is empty: {file_path}").into()); + } + check()?; + + let mut source = Source::open(&mut file)?; + let sr = source.sample_rate(); + if sr == 0 { + return Err("invalid declared sample rate 0".to_string().into()); + } + let total_duration = source.total_frames() as f64 / f64::from(sr); + if total_duration <= 0.0 { + return Err("file reports no duration, likely corrupt" + .to_string() + .into()); + } + + let analyzed_duration = (options.sample_seconds as f64).min(total_duration); + let offset = ((total_duration - analyzed_duration) / 2.0).max(0.0); + let start_frame = (offset * f64::from(sr)) as u64; + let window_frames = (analyzed_duration * f64::from(sr)) as u64; + + let window = source + .read_window(start_frame, window_frames, check) + .map_err(|e| HiResCheckError::Failed(format!("could not decode audio: {e}")))?; + let y = &window.mono; + if y.is_empty() { + return Err("decoded audio segment is empty".to_string().into()); + } + + let mut result = HiResCheckResult { + file_path: file_path.to_string(), + declared_sample_rate: sr, + total_duration_s: total_duration, + analyzed_duration_s: analyzed_duration, + noise_floor_db: options.noise_floor_db, + verdict: VERDICT_INCONCLUSIVE.into(), + ..HiResCheckResult::default() + }; + + // A silent segment makes spectral analysis meaningless rather than wrong. + if !y.iter().any(|v| f64::from(v.abs()) > 1e-9) { + return Ok(result); + } + + // Shrink n_fft for very short segments so the window does not end up + // measuring its own zero padding. + let mut n_fft = options.n_fft as usize; + while n_fft > 256 && n_fft > y.len() * 2 { + n_fft /= 2; + } + + let stats = analyze_stft(y, n_fft, sr, check)?; + if !stats.avg_magnitude.iter().any(|&v| v > 0.0) { + return Ok(result); + } + + // Deliberately unclamped: flooring at the noise floor itself would make + // every floored bin count as active at any lower threshold. + let spec_db = spectrum_db(&stats.avg_magnitude); + let cutoff = spec_db + .iter() + .rposition(|&db| db > options.noise_floor_db) + .map_or(0.0, |k| k as f64 * f64::from(sr) / n_fft as f64); + result.cutoff_frequency_hz = cutoff; + + let declared_bits = source.declared_bits(); + let mut effective_bits = 0; + if declared_bits > 0 && window.or_bits != 0 { + // Digital silence carries no bits at all; leaving it at 0 keeps it + // out of the padded-depth test. + effective_bits = declared_bits.saturating_sub(window.or_bits.trailing_zeros()); + } + result.declared_bit_depth = declared_bits; + result.effective_bit_depth = effective_bits; + + // A file can claim Hi-Res by rate, by depth, or both, and each claim is + // answered by the test that can actually judge it. + let claims_by_rate = i64::from(sr) > options.hires_sample_rate_threshold; + let claims_by_depth = declared_bits > 16; + result.useful_sample_rate = sr; + if claims_by_rate { + result.music_cutoff_hz = music_cutoff( + &stats.music_band_spreads, + &spec_db, + sr, + n_fft, + options.noise_floor_db, + ); + if result.music_cutoff_hz > 0.0 + && cutoff - result.music_cutoff_hz >= ULTRASONIC_NOISE_MARGIN_HZ + { + result.ultrasonic_noise_only = true; + result.useful_sample_rate = useful_sample_rate(sr, result.music_cutoff_hz); + } + // A resampler's cliff at 22.05/24 kHz betrays a 44.1/48 kHz chain + // even when a weak stopband leaves a flat plateau above it that reads + // as "content" to the cutoff test (ffmpeg's default resampler does). + result.brickwall_hz = detect_brickwall(&spec_db, sr, n_fft, options.noise_floor_db); + } + let cutoff_is_low = claims_by_rate && cutoff < options.hires_cutoff_threshold_hz; + let rate_is_fake = cutoff_is_low || result.brickwall_hz > 0.0; + let depth_is_fake = claims_by_depth && effective_bits > 0 && effective_bits <= 16; + + // Exact fingerprints of a conversion. Imaging puts content back above + // 22 kHz, so such a file can pass the cutoff test and still be a fake. + let mut artifact = ""; + if claims_by_rate { + artifact = detect_integer_upsampling(&window.first_channel, window.or_bits, sr); + if artifact.is_empty() && detect_imaging(&spec_db, sr, n_fft, options.noise_floor_db) { + artifact = ARTIFACT_IMAGING; + } + } + result.upsampling_artifact = artifact.into(); + if rate_is_fake { + let (class, vs_16bit_db) = + classify_noise_floor(stats.quiet_floor_var, source.channel_count()); + result.noise_floor_class = class.into(); + result.noise_floor_vs_16bit_db = vs_16bit_db; + } + + result.verdict = if !claims_by_rate && !claims_by_depth { + VERDICT_STANDARD + } else if rate_is_fake || depth_is_fake || !artifact.is_empty() { + VERDICT_FAKE + } else { + VERDICT_GENUINE + } + .into(); + + if result.verdict == VERDICT_FAKE { + result.confidence = if depth_is_fake || !artifact.is_empty() { + CONFIDENCE_CERTAIN + } else if result.brickwall_hz > 0.0 && result.noise_floor_class == FLOOR_AT_16BIT { + CONFIDENCE_LIKELY + } else { + CONFIDENCE_SUSPECT + } + .into(); + } + + let mut findings: Vec = Vec::new(); + match artifact { + ARTIFACT_SAMPLE_HOLD => { + findings.push("every sample is repeated (sample-and-hold upsampling)".into()); + } + ARTIFACT_INTERPOLATION => { + findings.push("in-between samples are linearly interpolated".into()); + } + ARTIFACT_IMAGING => { + findings.push("content above the source Nyquist mirrors the audible band".into()); + } + _ => {} + } + if cutoff_is_low { + findings.push(format!( + "declares {sr} Hz but content stops at ~{cutoff:.0} Hz" + )); + } else if rate_is_fake { + findings.push(format!( + "declares {sr} Hz but the spectrum falls off a cliff at {:.0} Hz", + result.brickwall_hz + )); + } + if depth_is_fake { + findings.push(format!( + "declares {declared_bits}-bit but only {effective_bits} bits carry data" + )); + } + if result.confidence == CONFIDENCE_SUSPECT { + findings.push("may be a genuine master low-pass filtered in mastering".into()); + } + result.reason = findings.join("; "); + Ok(result) +} + +/// The containers the checker decodes, sniffed from their magic bytes rather +/// than the extension so Android descriptor paths without a suffix work too. +enum Source<'a> { + Flac(Box>>), + Wav(WavSource<'a>), +} + +impl<'a> Source<'a> { + fn open(file: &'a mut File) -> Result { + let mut head = [0u8; 12]; + let mut read = 0; + while read < head.len() { + match file.read(&mut head[read..]) { + Ok(0) => break, + Ok(n) => read += n, + Err(e) => return Err(e.to_string().into()), + } + } + file.seek(SeekFrom::Start(0)).map_err(|e| e.to_string())?; + if read >= 4 && &head[..4] == b"fLaC" { + return claxon::FlacReader::new(BufReader::new(file)) + .map(|reader| Source::Flac(Box::new(reader))) + .map_err(|e| HiResCheckError::Failed(format!("could not read audio header: {e}"))); + } + if read >= 12 && &head[..4] == b"RIFF" && &head[8..12] == b"WAVE" { + return WavSource::open(file).map(Source::Wav); + } + Err(HiResCheckError::Unsupported) + } + + fn sample_rate(&self) -> u32 { + match self { + Source::Flac(reader) => reader.streaminfo().sample_rate, + Source::Wav(wav) => wav.sample_rate, + } + } + + fn total_frames(&self) -> u64 { + match self { + Source::Flac(reader) => reader.streaminfo().samples.unwrap_or(0), + Source::Wav(wav) => wav.data_size / wav.frame_bytes(), + } + } + + /// 0 for formats without a fixed-point depth. + fn declared_bits(&self) -> u32 { + match self { + Source::Flac(reader) => reader.streaminfo().bits_per_sample, + Source::Wav(wav) if wav.is_float => 0, + Source::Wav(wav) => wav.container_bits, + } + } + + fn channel_count(&self) -> u32 { + match self { + Source::Flac(reader) => reader.streaminfo().channels, + Source::Wav(wav) => wav.channels, + } + } + + fn read_window( + &mut self, + start_frame: u64, + frames: u64, + check: &dyn Fn() -> Result<(), String>, + ) -> Result { + match self { + Source::Flac(reader) => read_flac_window(reader, start_frame, frames, check), + Source::Wav(wav) => wav.read_window(start_frame, frames, check), + } + } +} + +/// Decodes [start_frame, start_frame + frames). claxon hands back samples +/// right-justified with wasted bits already shifted back in and inter-channel +/// decorrelation undone, so each value is the stored sample. It cannot seek, +/// so the frames before the window are decoded and skipped. +fn read_flac_window( + reader: &mut claxon::FlacReader>, + start_frame: u64, + frames: u64, + check: &dyn Fn() -> Result<(), String>, +) -> Result { + let bits = reader.streaminfo().bits_per_sample; + let scale = 1.0 / 2f64.powi(bits as i32 - 1); + let capacity = usize::try_from(frames).unwrap_or(0); + let mut out = Window { + mono: Vec::with_capacity(capacity), + first_channel: Vec::with_capacity(capacity), + ..Window::default() + }; + let end = start_frame + frames; + let mut blocks = reader.blocks(); + let mut buffer = Vec::new(); + let mut decoded = 0u64; + while (out.mono.len() as u64) < frames { + if decoded.is_multiple_of(64) { + check()?; + } + decoded += 1; + let block = match blocks.read_next_or_eof(buffer) { + Ok(Some(block)) => block, + Ok(None) => break, + // A truncated tail still leaves a usable window. + Err(_) if !out.mono.is_empty() => break, + Err(e) => return Err(e.to_string()), + }; + let first = block.time(); + let n = u64::from(block.duration()); + if first + n > start_frame { + let channels = block.channels(); + let from = start_frame.saturating_sub(first); + let to = n.min(end - first); + for i in from..to { + let i = i as usize; + let mut sum = 0.0; + for ch in 0..channels { + let v = block.channel(ch)[i]; + out.or_bits |= v as u32; + sum += f64::from(v); + } + out.first_channel.push(block.channel(0)[i]); + out.mono.push((sum / f64::from(channels) * scale) as f32); + } + } + buffer = block.into_buffer(); + } + Ok(out) +} + +const WAV_FORMAT_PCM: u16 = 1; +const WAV_FORMAT_FLOAT: u16 = 3; +const WAV_FORMAT_EXTENSIBLE: u16 = 0xFFFE; + +struct WavSource<'a> { + file: &'a mut File, + sample_rate: u32, + channels: u32, + /// Bits per sample as stored. + container_bits: u32, + is_float: bool, + data_offset: u64, + data_size: u64, +} + +impl<'a> WavSource<'a> { + fn open(file: &'a mut File) -> Result { + let file_size = file.metadata().map_err(|e| e.to_string())?.len(); + file.seek(SeekFrom::Start(12)).map_err(|e| e.to_string())?; + let (mut sample_rate, mut channels, mut container_bits) = (0u32, 0u32, 0u32); + let mut format = 0u16; + let (mut data_offset, mut data_size) = (0u64, 0u64); + let mut header = [0u8; 8]; + while data_offset == 0 { + if file.read_exact(&mut header).is_err() { + break; + } + let size = u64::from(u32::from_le_bytes([ + header[4], header[5], header[6], header[7], + ])); + let pad = size & 1; + match &header[..4] { + b"fmt " => { + let mut chunk = vec![0u8; usize::try_from(size).unwrap_or(0)]; + if file.read_exact(&mut chunk).is_err() || chunk.len() < 16 { + return Err("truncated WAV fmt chunk".to_string().into()); + } + format = u16::from_le_bytes([chunk[0], chunk[1]]); + channels = u32::from(u16::from_le_bytes([chunk[2], chunk[3]])); + sample_rate = u32::from_le_bytes([chunk[4], chunk[5], chunk[6], chunk[7]]); + container_bits = u32::from(u16::from_le_bytes([chunk[14], chunk[15]])); + if format == WAV_FORMAT_EXTENSIBLE && chunk.len() >= 26 { + // SubFormat GUID's leading format tag. + format = u16::from_le_bytes([chunk[24], chunk[25]]); + } + file.seek(SeekFrom::Current(pad as i64)) + .map_err(|e| e.to_string())?; + } + b"data" => { + data_offset = file.stream_position().map_err(|e| e.to_string())?; + // Streamed WAVs often carry a placeholder data size. + data_size = size.min(file_size.saturating_sub(data_offset)); + } + _ => { + file.seek(SeekFrom::Current((size + pad) as i64)) + .map_err(|e| e.to_string())?; + } + } + } + + if data_offset == 0 || channels == 0 || sample_rate == 0 { + return Err("WAV has no usable fmt/data chunk".to_string().into()); + } + let is_float = match (format, container_bits) { + (WAV_FORMAT_PCM, 8 | 16 | 24 | 32) => false, + (WAV_FORMAT_FLOAT, 32 | 64) => true, + _ => return Err(HiResCheckError::Unsupported), + }; + Ok(Self { + file, + sample_rate, + channels, + container_bits, + is_float, + data_offset, + data_size, + }) + } + + fn frame_bytes(&self) -> u64 { + u64::from(self.channels * self.container_bits / 8) + } + + fn read_window( + &mut self, + start_frame: u64, + frames: u64, + check: &dyn Fn() -> Result<(), String>, + ) -> Result { + let total = self.data_size / self.frame_bytes(); + let frames = frames.min(total.saturating_sub(start_frame)); + let mut out = Window::default(); + if frames == 0 { + return Ok(out); + } + let frame_bytes = self.frame_bytes() as usize; + self.file + .seek(SeekFrom::Start( + self.data_offset + start_frame * frame_bytes as u64, + )) + .map_err(|e| e.to_string())?; + let capacity = usize::try_from(frames).unwrap_or(0); + out.mono.reserve(capacity); + if !self.is_float { + out.first_channel.reserve(capacity); + } + let bytes_per_sample = (self.container_bits / 8) as usize; + let scale = 1.0 / 2f64.powi(self.container_bits as i32 - 1); + let mut reader = BufReader::with_capacity(1 << 16, &mut *self.file); + let mut frame = vec![0u8; frame_bytes]; + + for index in 0..frames { + if index.is_multiple_of(65_536) { + check()?; + } + if reader.read_exact(&mut frame).is_err() { + break; + } + let mut sum = 0.0; + for (c, b) in frame.chunks_exact(bytes_per_sample).enumerate() { + if self.is_float { + sum += if self.container_bits == 32 { + f64::from(f32::from_le_bytes([b[0], b[1], b[2], b[3]])) + } else { + f64::from_le_bytes([b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7]]) + }; + continue; + } + let v: i32 = match self.container_bits { + 8 => i32::from(b[0]) - 128, // 8-bit WAV is unsigned + 16 => i32::from(i16::from_le_bytes([b[0], b[1]])), + 24 => i32::from_le_bytes([0, b[0], b[1], b[2]]) >> 8, + _ => i32::from_le_bytes([b[0], b[1], b[2], b[3]]), + }; + out.or_bits |= v as u32; + if c == 0 { + out.first_channel.push(v); + } + sum += f64::from(v) * scale; + } + out.mono.push((sum / f64::from(self.channels)) as f32); + } + Ok(out) + } +} diff --git a/rust_backend/crates/core/src/media/hires/tests.rs b/rust_backend/crates/core/src/media/hires/tests.rs new file mode 100644 index 00000000..63d8abe2 --- /dev/null +++ b/rust_backend/crates/core/src/media/hires/tests.rs @@ -0,0 +1,736 @@ +//! Port of SpotiFLAC-Module-Version's tests/test_hires_check.py. Signals are +//! synthesised rather than fixtured: a "fake Hi-Res" file is exactly a +//! band-limited 44.1 kHz signal carried at a higher rate, which a few lines +//! build more clearly than any committed binary could describe. + +use super::fft::Radix2Fft; +use super::*; +use std::path::{Path, PathBuf}; + +const HIRES_SR: u32 = 176_400; +const CD_SR: u32 = 44_100; +/// A power of two so the frequency-domain construction can use the checker's +/// own FFT; ~3 s at 176.4 kHz. +const HIRES_FRAMES: usize = 1 << 19; +/// ~3 s at 44.1 kHz; x4 is HIRES_FRAMES. +const CD_SOURCE_FRAMES: usize = HIRES_FRAMES / 4; + +/// SplitMix64: deterministic noise without a dependency. +struct Rng(u64); + +impl Rng { + fn next_u64(&mut self) -> u64 { + self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = self.0; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) + } + + /// Uniform in [0, 1). + fn uniform(&mut self) -> f64 { + (self.next_u64() >> 11) as f64 / (1u64 << 53) as f64 + } + + /// Standard normal via Box-Muller. + fn normal(&mut self) -> f64 { + let u1 = self.uniform().max(1e-300); + let u2 = self.uniform(); + (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos() + } +} + +fn full_band_noise(n: usize, seed: u64) -> Vec { + let mut rng = Rng(seed); + (0..n).map(|_| rng.normal() * 0.2).collect() +} + +/// Applies `gain(freq)` to `y`'s spectrum; `y.len()` must be a power of two. +fn shape_spectrum(y: &[f64], sample_rate: u32, gain: impl Fn(f64) -> f64) -> Vec { + let n = y.len(); + let mut re = y.to_vec(); + let mut im = vec![0.0; n]; + let fft = Radix2Fft::new(n); + fft.transform(&mut re, &mut im); + for k in 0..=n / 2 { + let g = gain(k as f64 * f64::from(sample_rate) / n as f64); + re[k] *= g; + im[k] *= g; + if k > 0 && k < n / 2 { + re[n - k] = re[k]; + im[n - k] = -im[k]; + } + } + // Inverse FFT via conjugation. + im.iter_mut().for_each(|v| *v = -*v); + fft.transform(&mut re, &mut im); + re.iter().map(|v| v / n as f64).collect() +} + +/// CD-bandwidth noise in a 176.4 kHz container. Everything above 22.05 kHz is +/// zeroed, then a raised-cosine taper runs up to ~24.5 kHz: the +/// transition-band tail a real resampler leaves behind. +fn upsampled_from_cd() -> Vec { + let (cd_nyquist, taper_end) = (f64::from(CD_SR) / 2.0, 24_500.0); + shape_spectrum(&full_band_noise(HIRES_FRAMES, 0), HIRES_SR, |freq| { + if freq >= taper_end { + 0.0 + } else if freq >= cd_nyquist { + let ramp = (freq - cd_nyquist) / (taper_end - cd_nyquist); + 0.5 * (1.0 + (std::f64::consts::PI * ramp).cos()) * 1e-3 + } else { + 1.0 + } + }) +} + +/// Band-limited interpolation by an integer ratio: nothing at all above the +/// source Nyquist. +fn ideal_upsample(x: &[f64], ratio: usize) -> Vec { + let (n, m) = (x.len(), x.len() * ratio); + let mut re = x.to_vec(); + let mut im = vec![0.0; n]; + Radix2Fft::new(n).transform(&mut re, &mut im); + let (mut out_re, mut out_im) = (vec![0.0; m], vec![0.0; m]); + for k in 0..n / 2 { + out_re[k] = re[k]; + out_im[k] = im[k]; + if k > 0 { + out_re[m - k] = re[n - k]; + out_im[m - k] = im[n - k]; + } + } + out_re[n / 2] = re[n / 2] / 2.0; + out_re[m - n / 2] = re[n / 2] / 2.0; + out_im.iter_mut().for_each(|v| *v = -*v); + Radix2Fft::new(m).transform(&mut out_re, &mut out_im); + out_re.iter().map(|v| v / n as f64).collect() +} + +/// Loud noise, then a half holding only `quiet_noise`: the gaps real music +/// leaves, where a noise floor shows through. +fn cd_source_with_quiet_half(quiet_noise: f64) -> Vec { + let mut rng = Rng(3); + (0..CD_SOURCE_FRAMES) + .map(|i| { + rng.normal() + * if i < CD_SOURCE_FRAMES / 2 { + 0.2 + } else { + quiet_noise + } + }) + .collect() +} + +/// What a CD master is: TPDF-dithered 16-bit, as floats. +fn dither_to_16_bit(y: &[f64]) -> Vec { + let mut rng = Rng(4); + y.iter() + .map(|v| { + let dither = (rng.uniform() - rng.uniform()) / 32768.0; + ((v + dither) * 32768.0).round() / 32768.0 + }) + .collect() +} + +/// Maps [-1, 1) floats to integers of the given depth. +fn quantize(y: &[f64], bits: u32) -> Vec { + let full = 2f64.powi(bits as i32 - 1); + y.iter() + .map(|v| (v * full).round().clamp(-full, full - 1.0) as i32) + .collect() +} + +/// Noise occupying exactly `used_bits`, stored right-justified at +/// `container_bits`: a 16-bit master padded into 24 bits has its low 8 bits +/// zero. +fn pcm_using_bits(used_bits: u32, container_bits: u32, n: usize, seed: u64) -> Vec { + let mut rng = Rng(seed); + let span = 1i64 << used_bits; + (0..n) + .map(|_| { + let v = (rng.next_u64() % span as u64) as i64 - span / 2; + (v << (container_bits - used_bits)) as i32 + }) + .collect() +} + +fn fade(signal: &mut [f64]) { + const FADE: usize = 2048; + let n = signal.len(); + for i in 0..FADE { + let ramp = 0.5 - 0.5 * (std::f64::consts::PI * i as f64 / FADE as f64).cos(); + signal[i] *= ramp; + signal[n - 1 - i] *= ramp; + } +} + +struct TempDir(PathBuf); + +impl TempDir { + fn new(name: &str) -> Self { + let path = std::env::temp_dir().join(format!( + "spotiflac-hires-{name}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.as_nanos()) + )); + std::fs::create_dir_all(&path).expect("temp dir"); + Self(path) + } + + fn file(&self, name: &str) -> PathBuf { + self.0.join(name) + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +struct BitWriter { + bytes: Vec, + bit: u32, +} + +impl BitWriter { + fn new() -> Self { + Self { + bytes: Vec::new(), + bit: 0, + } + } + + fn put(&mut self, value: u64, width: u32) { + for shift in (0..width).rev() { + if self.bit == 0 { + self.bytes.push(0); + } + let last = self.bytes.len() - 1; + self.bytes[last] |= (((value >> shift) & 1) as u8) << (7 - self.bit); + self.bit = (self.bit + 1) % 8; + } + } +} + +fn crc8(data: &[u8]) -> u8 { + data.iter().fold(0u8, |mut crc, &byte| { + crc ^= byte; + for _ in 0..8 { + crc = if crc & 0x80 != 0 { + (crc << 1) ^ 0x07 + } else { + crc << 1 + }; + } + crc + }) +} + +fn crc16(data: &[u8]) -> u16 { + data.iter().fold(0u16, |mut crc, &byte| { + crc ^= u16::from(byte) << 8; + for _ in 0..8 { + crc = if crc & 0x8000 != 0 { + (crc << 1) ^ 0x8005 + } else { + crc << 1 + }; + } + crc + }) +} + +/// FLAC's UTF-8-like coding of the frame number. +fn utf8_number(value: u64) -> Vec { + if value < 0x80 { + return vec![value as u8]; + } + let mut continuation = Vec::new(); + let mut rest = value; + let mut payload_bits = 6; // bits left for the leading byte's payload + while rest >= 1 << payload_bits { + continuation.push(0x80 | (rest & 0x3F) as u8); + rest >>= 6; + payload_bits -= 1; + } + let count = continuation.len() + 1; + let lead = (0xFFu16 << (8 - count)) as u8 | rest as u8; + std::iter::once(lead) + .chain(continuation.into_iter().rev()) + .collect() +} + +/// Encodes `samples` (identical on every channel) as FLAC with verbatim +/// subframes: no compression, but every CRC valid, so any decoder reads it. +fn write_flac(path: &Path, samples: &[i32], sample_rate: u32, bits: u32, channels: u32) { + const BLOCK: usize = 4096; + let mut out = b"fLaC".to_vec(); + let mut info = BitWriter::new(); + info.put(BLOCK as u64, 16); + info.put(BLOCK as u64, 16); + info.put(0, 24); + info.put(0, 24); + info.put(u64::from(sample_rate), 20); + info.put(u64::from(channels - 1), 3); + info.put(u64::from(bits - 1), 5); + info.put(samples.len() as u64, 36); + info.put(0, 64); + info.put(0, 64); + out.extend_from_slice(&[0x80, 0, 0, 34]); // last metadata block, STREAMINFO + out.extend_from_slice(&info.bytes); + + for (number, block) in samples.chunks(BLOCK).enumerate() { + // Fixed blocking; 16-bit block size; sample rate from STREAMINFO; + // independent channels; the depth spelled out, as encoders do. + let depth_code = match bits { + 8 => 0b001, + 12 => 0b010, + 16 => 0b100, + 20 => 0b101, + _ => 0b110, // 24 + }; + let mut frame = vec![ + 0xFF, + 0xF8, + 0x70, + (((channels - 1) << 4) | (depth_code << 1)) as u8, + ]; + frame.extend(utf8_number(number as u64)); + frame.extend_from_slice(&((block.len() - 1) as u16).to_be_bytes()); + frame.push(crc8(&frame)); + let mut body = BitWriter::new(); + for _ in 0..channels { + body.put(0b0000_0010, 8); // verbatim, no wasted bits + for &sample in block { + body.put(u64::from(sample as u32) & ((1u64 << bits) - 1), bits); + } + } + frame.extend(body.bytes); + let crc = crc16(&frame); + frame.extend_from_slice(&crc.to_be_bytes()); + out.extend(frame); + } + std::fs::write(path, out).expect("write flac"); +} + +fn write_wav(path: &Path, samples: &[i32], sample_rate: u32, bits: u32) { + let bytes_per = (bits / 8) as usize; + let data: Vec = samples + .iter() + .flat_map(|v| v.to_le_bytes()[..bytes_per].to_vec()) + .collect(); + let mut out = Vec::new(); + out.extend_from_slice(b"RIFF"); + out.extend_from_slice(&(36 + data.len() as u32).to_le_bytes()); + out.extend_from_slice(b"WAVEfmt "); + out.extend_from_slice(&16u32.to_le_bytes()); + out.extend_from_slice(&1u16.to_le_bytes()); + out.extend_from_slice(&1u16.to_le_bytes()); + out.extend_from_slice(&sample_rate.to_le_bytes()); + out.extend_from_slice(&(sample_rate * bytes_per as u32).to_le_bytes()); + out.extend_from_slice(&(bytes_per as u16).to_le_bytes()); + out.extend_from_slice(&(bits as u16).to_le_bytes()); + out.extend_from_slice(b"data"); + out.extend_from_slice(&(data.len() as u32).to_le_bytes()); + out.extend(data); + std::fs::write(path, out).expect("write wav"); +} + +fn run_check( + path: &Path, + options: &HiResCheckOptions, +) -> Result { + let file = File::open(path).expect("open fixture"); + check_file(file, &path.to_string_lossy(), options, &|| Ok(())) +} + +fn check(path: &Path) -> HiResCheckResult { + run_check(path, &HiResCheckOptions::default()).expect("check") +} + +fn flac(dir: &TempDir, name: &str, samples: &[i32], sample_rate: u32, bits: u32) -> PathBuf { + let path = dir.file(name); + write_flac(&path, samples, sample_rate, bits, 1); + path +} + +#[test] +fn radix2_fft_matches_a_direct_dft() { + const N: usize = 64; + let mut rng = Rng(2); + let input: Vec = (0..N).map(|_| rng.uniform() - 0.5).collect(); + let (mut re, mut im) = (input.clone(), vec![0.0; N]); + Radix2Fft::new(N).transform(&mut re, &mut im); + for k in 0..N { + let (mut want_re, mut want_im) = (0.0, 0.0); + for (j, v) in input.iter().enumerate() { + let angle = -2.0 * std::f64::consts::PI * (k * j) as f64 / N as f64; + want_re += v * angle.cos(); + want_im += v * angle.sin(); + } + assert!((re[k] - want_re).hypot(im[k] - want_im) < 1e-9, "bin {k}"); + } +} + +#[test] +fn flac_frame_numbers_use_the_utf8_like_coding() { + assert_eq!(utf8_number(0x7F), vec![0x7F]); + assert_eq!(utf8_number(0x80), vec![0xC2, 0x80]); + assert_eq!(utf8_number(0x7FF), vec![0xDF, 0xBF]); + assert_eq!(utf8_number(0x800), vec![0xE0, 0xA0, 0x80]); +} + +#[test] +fn genuine_hires_is_not_flagged() { + let dir = TempDir::new("genuine"); + let samples = quantize(&full_band_noise(HIRES_FRAMES, 0), 24); + let r = check(&flac(&dir, "genuine.flac", &samples, HIRES_SR, 24)); + assert_eq!(r.verdict, VERDICT_GENUINE); + assert_eq!(r.upsampling_artifact, ""); + assert_eq!(r.brickwall_hz, 0.0); + assert_eq!(r.confidence, ""); +} + +#[test] +fn an_upsampled_cd_signal_that_is_never_quiet_is_only_suspect() { + let dir = TempDir::new("upsampled"); + let samples = quantize(&upsampled_from_cd(), 24); + let r = check(&flac(&dir, "fake.flac", &samples, HIRES_SR, 24)); + assert_eq!(r.verdict, VERDICT_FAKE); + assert!(r.is_suspicious()); + assert!( + r.cutoff_frequency_hz > 22_000.0 && r.cutoff_frequency_hz < 28_000.0, + "{}", + r.cutoff_frequency_hz + ); + assert!(r.reason.contains("content stops"), "{}", r.reason); + // Loud from start to end: the floor cannot be read, so a genuine master + // filtered at 22 kHz would look the same. Flagged, never replaced. + assert_eq!(r.brickwall_hz, 22_050.0); + assert_eq!(r.noise_floor_class, FLOOR_MASKED); + assert_eq!(r.confidence, CONFIDENCE_SUSPECT); + assert!(!r.redownload_safe()); +} + +#[test] +fn a_cd_file_claims_nothing() { + let dir = TempDir::new("cd"); + let samples = quantize(&full_band_noise(CD_SR as usize * 3, 0), 16); + let r = check(&flac(&dir, "cd.flac", &samples, CD_SR, 16)); + assert_eq!(r.verdict, VERDICT_STANDARD); + assert!(!r.is_suspicious()); +} + +/// 24-bit/44.1 kHz claims Hi-Res by depth alone, which no spectral test can +/// judge: what gives a padded CD master away is its always-zero low 8 bits. +#[test] +fn a_padded_24_bit_cd_master_is_certain() { + let dir = TempDir::new("padded"); + let samples = pcm_using_bits(16, 24, CD_SR as usize * 3, 1); + let r = check(&flac(&dir, "padded.flac", &samples, CD_SR, 24)); + assert_eq!(r.verdict, VERDICT_FAKE); + assert_eq!((r.declared_bit_depth, r.effective_bit_depth), (24, 16)); + assert!(r.padded_bit_depth()); + assert!( + r.reason.contains("24-bit") && r.reason.contains("16 bits"), + "{}", + r.reason + ); + // The spectral half made no finding, so it must not appear in the reason. + assert!(!r.reason.contains("content stops"), "{}", r.reason); + assert_eq!(r.confidence, CONFIDENCE_CERTAIN); + assert!(r.redownload_safe()); +} + +#[test] +fn a_real_24_bit_cd_rate_file_is_not_flagged() { + let dir = TempDir::new("real24"); + let samples = pcm_using_bits(24, 24, CD_SR as usize * 3, 1); + let r = check(&flac(&dir, "real24.flac", &samples, CD_SR, 24)); + assert_eq!(r.verdict, VERDICT_GENUINE); + assert_eq!((r.declared_bit_depth, r.effective_bit_depth), (24, 24)); + assert!(!r.padded_bit_depth()); +} + +/// A 16-bit container declares no Hi-Res depth, so the spectral verdict is the +/// whole answer. +#[test] +fn sixteen_bit_makes_no_depth_claim() { + let dir = TempDir::new("hires16"); + let samples = quantize(&full_band_noise(HIRES_FRAMES, 0), 16); + let r = check(&flac(&dir, "hires16.flac", &samples, HIRES_SR, 16)); + assert_eq!(r.declared_bit_depth, 16); + assert!(!r.padded_bit_depth()); + assert_eq!(r.verdict, VERDICT_GENUINE); +} + +#[test] +fn silence_is_inconclusive() { + let dir = TempDir::new("silent"); + let r = check(&flac( + &dir, + "silent.flac", + &vec![0; HIRES_FRAMES], + HIRES_SR, + 24, + )); + assert_eq!(r.verdict, VERDICT_INCONCLUSIVE); +} + +/// A floor below -80 dB must still measure the spectrum rather than report +/// the full Nyquist frequency as content for every file. +#[test] +fn a_lower_noise_floor_still_measures() { + let dir = TempDir::new("floor"); + let samples = quantize(&upsampled_from_cd(), 24); + let path = flac(&dir, "fake.flac", &samples, HIRES_SR, 24); + let options = HiResCheckOptions { + noise_floor_db: -90.0, + ..HiResCheckOptions::default() + }; + let r = run_check(&path, &options).expect("check"); + assert!( + r.cutoff_frequency_hz < f64::from(HIRES_SR) / 2.0, + "{}", + r.cutoff_frequency_hz + ); +} + +/// Real-world FLACs are stereo and longer than the 30 s window, which must be +/// taken from the middle. +#[test] +fn a_long_stereo_flac_is_read_from_the_middle() { + let dir = TempDir::new("stereo"); + let path = dir.file("stereo.flac"); + let samples = pcm_using_bits(16, 24, CD_SR as usize * 70, 1); + write_flac(&path, &samples, CD_SR, 24, 2); + let r = check(&path); + assert_eq!(r.analyzed_duration_s, 30.0); + assert_eq!(r.verdict, VERDICT_FAKE); + assert_eq!(r.effective_bit_depth, 16); +} + +#[test] +fn wav_is_checked_too() { + let dir = TempDir::new("wav"); + let padded = dir.file("padded.wav"); + write_wav( + &padded, + &pcm_using_bits(16, 24, CD_SR as usize * 3, 1), + CD_SR, + 24, + ); + let r = check(&padded); + assert_eq!( + (r.verdict.as_str(), r.effective_bit_depth), + (VERDICT_FAKE, 16) + ); + + let fake = dir.file("fake.wav"); + write_wav(&fake, &quantize(&upsampled_from_cd(), 24), HIRES_SR, 24); + let r = check(&fake); + assert_eq!( + (r.verdict.as_str(), r.effective_bit_depth), + (VERDICT_FAKE, 24) + ); +} + +#[test] +fn errors_and_unsupported_formats() { + let dir = TempDir::new("errors"); + let empty = dir.file("empty.flac"); + std::fs::write(&empty, b"").expect("write"); + let mp3 = dir.file("song.mp3"); + std::fs::write(&mp3, b"ID3\x04\x00\x00\x00\x00\x00\x00junk").expect("write"); + let corrupt = dir.file("corrupt.flac"); + std::fs::write(&corrupt, b"fLaC\x00\x00").expect("write"); + let options = HiResCheckOptions::default(); + + assert!(matches!( + run_check(&empty, &options), + Err(HiResCheckError::Failed(_)) + )); + assert_eq!(run_check(&mp3, &options), Err(HiResCheckError::Unsupported)); + assert!(matches!( + run_check(&corrupt, &options), + Err(HiResCheckError::Failed(_)) + )); + let bad = HiResCheckOptions { + n_fft: 1000, + ..HiResCheckOptions::default() + }; + assert!(matches!( + run_check(&mp3, &bad), + Err(HiResCheckError::Failed(_)) + )); +} + +#[test] +fn a_cancelled_check_stops() { + let dir = TempDir::new("cancel"); + let samples = quantize(&full_band_noise(HIRES_FRAMES, 0), 24); + let path = flac(&dir, "genuine.flac", &samples, HIRES_SR, 24); + let file = File::open(&path).expect("open"); + let result = check_file(file, "x", &HiResCheckOptions::default(), &|| { + Err("cancelled".into()) + }); + assert_eq!(result, Err(HiResCheckError::Failed("cancelled".into()))); +} + +/// A dithered 16-bit CD master upsampled cleanly: the cliff sits at 22.05 kHz +/// and the quiet half shows 16-bit dither noise. A LOSSLESS copy holds all of +/// it, so it is safe to replace. +#[test] +fn an_upsampled_16_bit_master_is_likely() { + let dir = TempDir::new("likely"); + let signal = ideal_upsample(&dither_to_16_bit(&cd_source_with_quiet_half(0.0)), 4); + let r = check(&flac( + &dir, + "cd16.flac", + &quantize(&signal, 24), + HIRES_SR, + 24, + )); + assert_eq!(r.verdict, VERDICT_FAKE); + assert_eq!(r.brickwall_hz, 22_050.0); + assert_eq!( + r.noise_floor_class, FLOOR_AT_16BIT, + "{} dB", + r.noise_floor_vs_16bit_db + ); + assert_eq!(r.confidence, CONFIDENCE_LIKELY); + assert!(r.redownload_safe()); +} + +/// The same cliff, but the quiet half carries detail far below 16-bit noise: +/// a 24-bit master made at 44.1 kHz. LOSSLESS would lose that depth. +#[test] +fn a_24_bit_master_made_at_44k_is_only_suspect() { + let dir = TempDir::new("master24"); + let signal = ideal_upsample(&cd_source_with_quiet_half(1e-6), 4); + let r = check(&flac( + &dir, + "master24.flac", + &quantize(&signal, 24), + HIRES_SR, + 24, + )); + assert_eq!(r.verdict, VERDICT_FAKE); + assert_eq!( + r.noise_floor_class, FLOOR_BELOW_16BIT, + "{} dB", + r.noise_floor_vs_16bit_db + ); + assert_eq!(r.confidence, CONFIDENCE_SUSPECT); + assert!(!r.redownload_safe()); + assert!(r.reason.contains("genuine master"), "{}", r.reason); +} + +/// A gradual mastering roll-off that still ends below 28 kHz: no resampler +/// cliff, so at most a suspect. Faded in and out: an abrupt start inside the +/// analyzed window is a step, whose broadband splatter reads as content. +#[test] +fn a_gradual_mastering_roll_off_is_only_suspect() { + let dir = TempDir::new("lpf"); + let mut signal = shape_spectrum(&full_band_noise(HIRES_FRAMES, 0), HIRES_SR, |freq| { + let gain_db = if freq > 16_000.0 { + -10.0 * (freq - 16_000.0) / 1000.0 + } else { + 0.0 + }; + 10f64.powf(gain_db / 20.0) + }); + fade(&mut signal); + let r = check(&flac( + &dir, + "lpf.flac", + &quantize(&signal, 24), + HIRES_SR, + 24, + )); + assert_eq!(r.verdict, VERDICT_FAKE); + assert_eq!(r.brickwall_hz, 0.0); + assert_eq!(r.confidence, CONFIDENCE_SUSPECT); +} + +/// 24-bit source samples (so the depth test passes) taken to 176.4 kHz by the +/// cheap upsamplers whose output is an exact, checkable pattern. +#[test] +fn integer_upsampling_artifacts_are_certain() { + let source = pcm_using_bits(24, 24, CD_SOURCE_FRAMES + 1, 1); + let (mut hold, mut line, mut zero_stuffed) = (Vec::new(), Vec::new(), Vec::new()); + for pair in source.windows(2) { + let (a, b) = (f64::from(pair[0]), f64::from(pair[1])); + for j in 0..4 { + hold.push(pair[0]); + line.push((a + (b - a) * f64::from(j) / 4.0).round() as i32); + zero_stuffed.push(if j == 0 { pair[0] / 2 } else { 0 }); + } + } + let dir = TempDir::new("artifacts"); + for (name, samples, artifact) in [ + ("hold", hold, ARTIFACT_SAMPLE_HOLD), + ("linear", line, ARTIFACT_INTERPOLATION), + ("zero", zero_stuffed, ARTIFACT_IMAGING), + ] { + let r = check(&flac(&dir, &format!("{name}.flac"), &samples, HIRES_SR, 24)); + assert_eq!(r.upsampling_artifact, artifact, "{name}"); + assert_eq!(r.verdict, VERDICT_FAKE, "{name}"); + assert_eq!(r.confidence, CONFIDENCE_CERTAIN, "{name}"); + } +} + +/// A DSD or analog-tape transfer: music that stops around 30 kHz, and a steady +/// ultrasonic noise hump running on to ~80 kHz. The active-content cutoff +/// marks the end of the hump; the music bandwidth must not, and the useful +/// rate is the 88.2 kHz that holds all the music. +#[test] +fn music_is_told_apart_from_ultrasonic_noise() { + let band = |seed, low: f64, high: f64| { + shape_spectrum(&full_band_noise(HIRES_FRAMES, seed), HIRES_SR, |freq| { + if (low..=high).contains(&freq) { + 1.0 + } else { + 0.0 + } + }) + }; + let (music, hump) = (band(5, 20.0, 30_000.0), band(6, 40_000.0, 80_000.0)); + let mut signal: Vec = music + .iter() + .zip(&hump) + .enumerate() + .map(|(i, (m, h))| { + // A 3 Hz swell swings the music ~20 dB; the hump never moves. + let phase = 2.0 * std::f64::consts::PI * 3.0 * i as f64 / f64::from(HIRES_SR); + m * (0.55 + 0.45 * phase.cos()) + 0.02 * h + }) + .collect(); + fade(&mut signal); + let dir = TempDir::new("dsd"); + let r = check(&flac( + &dir, + "dsd.flac", + &quantize(&signal, 24), + HIRES_SR, + 24, + )); + assert_eq!(r.verdict, VERDICT_GENUINE); + assert!( + r.cutoff_frequency_hz > 70_000.0, + "{}", + r.cutoff_frequency_hz + ); + assert!( + (28_000.0..=33_000.0).contains(&r.music_cutoff_hz), + "{}", + r.music_cutoff_hz + ); + assert!(r.ultrasonic_noise_only); + assert_eq!(r.useful_sample_rate, 88_200); +} diff --git a/rust_backend/crates/core/src/media/mod.rs b/rust_backend/crates/core/src/media/mod.rs index 66b65404..6fd2a103 100644 --- a/rust_backend/crates/core/src/media/mod.rs +++ b/rust_backend/crates/core/src/media/mod.rs @@ -1,6 +1,7 @@ //! Bounded, seek-based implementation of Go's FLAC/MP4 quality probe. mod audio; +pub mod hires; pub(crate) mod mp4; pub(crate) use audio::{mp3_quality, ogg_quality, riff_quality}; diff --git a/rust_backend/crates/mobile/src/hires.rs b/rust_backend/crates/mobile/src/hires.rs new file mode 100644 index 00000000..7b95d7db --- /dev/null +++ b/rust_backend/crates/mobile/src/hires.rs @@ -0,0 +1,76 @@ +use crate::cancellation::RequestLease; +use crate::tags::{check_lease, open_audio_file}; +use spotiflac_core::media::hires::{self, HiResCheckOptions}; +use std::sync::Arc; + +#[derive(Debug, thiserror::Error, uniffi::Error)] +#[uniffi(flat_error)] +pub enum HiResCheckError { + #[error("{message}")] + Failed { message: String }, +} + +impl From for HiResCheckError { + fn from(message: String) -> Self { + Self::Failed { message } + } +} + +/// Runs the fake Hi-Res check on one local FLAC or WAV file; `options_json` +/// may be empty or override any `HiResCheckOptions` field. A format the +/// checker cannot decode is not an error: it returns `{"supported": false}` +/// so the UI can say "not checkable" rather than report a broken file. +/// CPU-bound for a second or two: call it off the main thread. +#[uniffi::export] +pub fn check_hires_authenticity( + path: String, + options_json: String, + lease: Option>, +) -> Result { + let options: HiResCheckOptions = if options_json.trim().is_empty() { + HiResCheckOptions::default() + } else { + serde_json::from_str(&options_json) + .map_err(|error| format!("invalid hi-res check options: {error}"))? + }; + let check = || check_lease(lease.as_deref()); + check()?; + let file = open_audio_file(&path)?; + let result = match hires::check_file(file, &path, &options, &check) { + Ok(result) => result, + Err(hires::HiResCheckError::Unsupported) => { + return Ok(serde_json::json!({"supported": false, "file_path": path}).to_string()); + } + Err(error) => return Err(error.to_string().into()), + }; + let mut value = serde_json::to_value(&result).map_err(|error| error.to_string())?; + if let Some(object) = value.as_object_mut() { + object.insert("supported".into(), true.into()); + object.insert("is_suspicious".into(), result.is_suspicious().into()); + object.insert("padded_bit_depth".into(), result.padded_bit_depth().into()); + object.insert("redownload_safe".into(), result.redownload_safe().into()); + } + Ok(value.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unsupported_formats_are_not_errors_and_bad_options_are() { + let dir = + std::env::temp_dir().join(format!("spotiflac-hires-export-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("dir"); + let mp3 = dir.join("song.mp3"); + std::fs::write(&mp3, b"ID3\x04\x00\x00\x00\x00\x00\x00junk").expect("write"); + let path = mp3.to_string_lossy().to_string(); + + let raw = check_hires_authenticity(path.clone(), String::new(), None).expect("check"); + let value: serde_json::Value = serde_json::from_str(&raw).expect("json"); + assert_eq!(value["supported"], false); + + assert!(check_hires_authenticity(path, "{bad".into(), None).is_err()); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/rust_backend/crates/mobile/src/lib.rs b/rust_backend/crates/mobile/src/lib.rs index 96272226..a55c8555 100644 --- a/rust_backend/crates/mobile/src/lib.rs +++ b/rust_backend/crates/mobile/src/lib.rs @@ -4,6 +4,7 @@ mod cancellation; mod extensions; mod ffmpeg; mod filename; +mod hires; mod index; mod logging; mod lyrics; diff --git a/rust_backend/crates/mobile/src/tags.rs b/rust_backend/crates/mobile/src/tags.rs index a2558aa6..bbd2ea51 100644 --- a/rust_backend/crates/mobile/src/tags.rs +++ b/rust_backend/crates/mobile/src/tags.rs @@ -339,7 +339,7 @@ pub fn read_file_metadata( serde_json::to_string(&metadata).map_err(|error| error.to_string().into()) } -fn check_lease(lease: Option<&RequestLease>) -> Result<(), String> { +pub(crate) fn check_lease(lease: Option<&RequestLease>) -> Result<(), String> { lease.map_or(Ok(()), |lease| { lease .inner @@ -348,7 +348,7 @@ fn check_lease(lease: Option<&RequestLease>) -> Result<(), String> { }) } -fn open_audio_file(path: &str) -> Result { +pub(crate) fn open_audio_file(path: &str) -> Result { let mut options = OpenOptions::new(); options.read(true); #[cfg(unix)]