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)]