Merge pull request #589 with lossless replacement safeguards

Keep automatic Hi-Res replacement off by default. Require exact full-duration PCM equivalence across every channel before and after processing. Retain the original through publication and history persistence, restore it on failure or cancellation, and bound WAV format parsing.

Validated with 30 Rust tests, 46 Dart tests, clean analyzer/Clippy checks, Android ARM64 and iOS builds, and iOS simulator startup.
This commit is contained in:
zarzet committed 2026-09-25 01:02:39 +07:00
commit b7d4ceb7e1
36 files changed
+3690 -12

No files matched your search

+2 -2
View File
@@ -74,8 +74,8 @@ project boundaries, validation commands, and pull request checklist.
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
Download music in true lossless FLAC from extension-provided sources on Windows, macOS & Linux.
### [SpotiFLAC (Python Module)](https://github.com/ShuShuzinhuu/SpotiFLAC-Module-Version)
Python library for SpotiFLAC integration, maintained by [@ShuShuzinhuu](https://github.com/ShuShuzinhuu).
### [SpotiFLAC (Python Module)](https://github.com/BartolomeoRusso9/SpotiFLAC-Module-Version)
Python library for SpotiFLAC integration, maintained by [@BartolomeoRusso9](https://github.com/BartolomeoRusso9).
---
@@ -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
@@ -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<String>("file_path") ?: ""
val optionsJson = call.argument<String>("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<String>("file_path") ?: ""
val metadataJson = call.argument<String>("metadata_json") ?: "{}"
@@ -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,
@@ -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)
+7 -1
View File
@@ -245,7 +245,8 @@ import UniformTypeIdentifiers
"pickIosDirectory", "createIosBookmarkFromPath", "resolveIosBookmark", "startAccessingIosBookmark", "stopAccessingIosBookmark", "downloadCoverToFile", "releaseMemory", "releaseMemoryUnderPressure",
"setLibraryCoverCacheDir", "scanLibraryFolder", "scanLibraryFolderToNDJSONFile", "scanLibraryFolderIncremental",
"getLibraryScanProgress", "cancelLibraryScan", "parseCueSheet", "extractCoverToFile",
"rewriteSplitArtistTags", "writeM4AFreeformTags", "ensureAC4Config", "writeAC4Metadata", "reEnrichFile"]
"rewriteSplitArtistTags", "writeM4AFreeformTags", "ensureAC4Config", "writeAC4Metadata", "reEnrichFile",
"checkHiResAuthenticity"]
if coreBackend.routesApplication && !osMethods.contains(call.method) {
DispatchQueue.global(qos: .userInitiated).async {
do {
@@ -488,6 +489,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
+5
View File
@@ -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)
}
+125
View File
@@ -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": "Off by default. Check Hi-Res downloads and replace only when every audio sample is preserved. Keep the original if verification or processing fails",
"@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"
+125
View File
@@ -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": "Disattivato per impostazione predefinita. Sostituisce i download Hi-Res solo se tutti i campioni audio sono preservati. Mantiene l’originale se la verifica o l’elaborazione non riesce",
"@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"
+5
View File
@@ -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,
+2
View File
@@ -63,6 +63,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> 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<String, dynamic> _$AppSettingsToJson(
'locale': instance.locale,
'lyricsMode': instance.lyricsMode,
'autoConvertDownloads': instance.autoConvertDownloads,
'redownloadFakeHiRes': instance.redownloadFakeHiRes,
'autoConvertFormat': instance.autoConvertFormat,
'autoConvertBitrate': instance.autoConvertBitrate,
'useAllFilesAccess': instance.useAllFilesAccess,
@@ -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');
@@ -0,0 +1,250 @@
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. The original is only deleted after
// full PCM verification and successful finalization, publication and history.
// If any step fails, including cancellation, the original is restored.
enum _FakeHiResOutcome {
/// Nothing to do, or the replacement failed: finish with the current file.
kept,
/// The verified replacement is already finalized and persisted.
completed,
/// 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 = Map<String, dynamic>.from(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,
appOutputDir = run.appOutputDir,
filePath = run.filePath,
wasExisting = run.wasExisting,
actualQuality = run.actualQuality,
resultOutputExt = run.resultOutputExt,
shouldPreserveNativeM4a = run.shouldPreserveNativeM4a,
decryptionDescriptor = run.decryptionDescriptor,
probedFinalMetadata = run.probedFinalMetadata,
externalLrcWritten = run.externalLrcWritten;
final Map<String, dynamic> 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;
final String? appOutputDir;
final String? filePath;
final bool wasExisting;
final String actualQuality;
final String? resultOutputExt;
final bool shouldPreserveNativeM4a;
final DownloadDecryptionDescriptor? decryptionDescriptor;
final Map<String, dynamic>? probedFinalMetadata;
final bool externalLrcWritten;
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;
run.appOutputDir = appOutputDir;
run.filePath = filePath;
run.wasExisting = wasExisting;
run.actualQuality = actualQuality;
run.resultOutputExt = resultOutputExt;
run.shouldPreserveNativeM4a = shouldPreserveNativeM4a;
run.decryptionDescriptor = decryptionDescriptor;
run.probedFinalMetadata = probedFinalMetadata;
run.externalLrcWritten = externalLrcWritten;
}
}
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) {
// Evidence on one axis cannot justify discarding the other axis.
_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 saved = _FakeHiResRunState(this);
var completed = false;
var aborted = false;
try {
completed = await HiResCheckService.withOriginalBackup(path, (
backup,
) async {
fakeHiResOriginalPath = backup;
trackToDownload = requestTrack;
result = <String, dynamic>{};
filePath = null;
probedFinalMetadata = null;
externalLrcWritten = false;
var finalized = false;
try {
await _useFakeHiResFallbackQuality();
n.updateItemStatus(item.id, DownloadStatus.downloading, progress: 0);
if (!await _downloadAndMaybeFallback()) {
aborted = true;
return false;
}
aborted = await _shouldAbort('during fake Hi-Res re-download');
if (aborted) return false;
final replacementPath = result['file_path'] as String?;
if (result['success'] != true ||
result['already_exists'] == true ||
normalizeOptionalString(replacementPath) == null ||
isContentUri(replacementPath!)) {
return false;
}
if (effectiveSafMode && result['saf_relative_dir'] is String) {
effectiveOutputDir = n._sanitizeSafRelativeDir(
result['saf_relative_dir'] as String,
);
}
finalized = await _handleDownloadSuccess();
return finalized;
} finally {
if (!finalized && result['already_exists'] != true) {
for (final failedPath in {
filePath,
result['file_path'] as String?,
}) {
if (failedPath != null && failedPath != backup) {
await deleteFile(failedPath);
}
}
}
}
});
} on HiResOriginalRestoreException {
rethrow;
} catch (e) {
_log.w('[hires-check] re-download of ${item.track.name} failed: $e');
} finally {
fakeHiResOriginalPath = null;
if (!completed) saved.restore(this);
}
if (completed) return _FakeHiResOutcome.completed;
if (aborted || await _shouldAbort('after fake Hi-Res rollback')) {
return _FakeHiResOutcome.aborted;
}
if (!await File(path).exists()) {
throw StateError('Could not restore the original Hi-Res file: $path');
}
n.updateItemStatus(item.id, DownloadStatus.downloading, progress: 1);
return _FakeHiResOutcome.kept;
}
/// 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 <QualityOption>[];
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<void> _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;
}
}
}
@@ -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(
@@ -132,6 +132,8 @@ class _DownloadRun {
Map<String, dynamic>? probedFinalMetadata;
bool externalLrcWritten = false;
final Map<String, String> _directoryScopes = {};
bool fakeHiResChecked = false;
String? fakeHiResOriginalPath;
Future<void> _run() async {
final normalizedService = n._normalizeQueuedService(item.service);
@@ -729,6 +731,7 @@ class _DownloadRun {
decryptionDescriptor = DownloadDecryptionDescriptor.fromDownloadResult(
result,
);
final requestTrack = trackToDownload;
trackToDownload = buildTrackForMetadataEmbedding(
trackToDownload,
result,
@@ -741,6 +744,18 @@ class _DownloadRun {
await _applyFormatHandling(actualService);
stageCompleted('format and metadata');
final originalHiResPath = fakeHiResOriginalPath;
if (originalHiResPath != null &&
(filePath == null ||
!await HiResCheckService.replacementPreservesAudio(
originalHiResPath,
filePath!,
))) {
throw StateError(
'Lossless replacement does not preserve the original audio',
);
}
if (await _shouldAbort(
'during finalization',
deleteFileOnAbort: filePath,
@@ -748,6 +763,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.completed:
return true;
case _FakeHiResOutcome.aborted:
return false;
case _FakeHiResOutcome.kept:
break;
}
}
final deferredSafPublish =
effectiveSafMode &&
result['saf_deferred_publish'] == true &&
@@ -838,6 +866,23 @@ class _DownloadRun {
);
}
if (originalHiResPath != null &&
!await HiResCheckService.replacementPreservesAudio(
originalHiResPath,
filePath!,
)) {
throw StateError(
'Finalized replacement no longer preserves the original audio',
);
}
if (await _shouldAbort(
'before publishing finalized audio',
deleteFileOnAbort: wasExisting ? null : filePath,
)) {
return false;
}
if (deferredSafPublish && !await _publishDeferredSafOutputOnce()) {
throw StateError('Failed to publish deferred SAF output');
}
@@ -1762,13 +1807,19 @@ class _DownloadRun {
);
},
);
await n._notificationService.showDownloadComplete(
trackName: item.track.name,
artistName: item.track.artistName,
completedCount: n._completedInSession,
totalCount: n._totalQueuedAtStart,
alreadyInLibrary: wasExisting,
);
try {
await n._notificationService.showDownloadComplete(
trackName: item.track.name,
artistName: item.track.artistName,
completedCount: n._completedInSession,
totalCount: n._totalQueuedAtStart,
alreadyInLibrary: wasExisting,
);
} catch (e) {
// Audio and history are already committed. A notification failure
// must not roll back a valid replacement or its persisted history.
_log.w('Download completed but notification failed: $e');
}
n.removeItem(item.id);
}
}
+5
View File
@@ -792,6 +792,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setRedownloadFakeHiRes(bool enabled) {
state = state.copyWith(redownloadFakeHiRes: enabled);
_saveSettings();
}
void setAutoConvertFormat(String format) {
state = state.copyWith(
autoConvertFormat: normalizeAutoConvertFormat(format),
@@ -107,6 +107,15 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
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,
@@ -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,
+10
View File
@@ -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) ...[
+1
View File
@@ -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';
+162
View File
@@ -0,0 +1,162 @@
import 'dart:io';
import 'package:spotiflac_android/services/platform_bridge.dart';
class HiResOriginalRestoreException implements Exception {
const HiResOriginalRestoreException(this.backupPath, this.cause);
final String backupPath;
final FileSystemException cause;
@override
String toString() => 'Original audio retained at $backupPath: $cause';
}
/// Evidence from the Rust Hi-Res checker's sampled analysis.
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;
/// Eligible to try a replacement. Full-file verification is still required.
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<String, dynamic> 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 analysis 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'};
/// Requested fallback tier. Its name does not guarantee audio equivalence.
static const fakeHiResFallbackQuality = 'LOSSLESS';
static bool isHiResQuality(String quality) =>
hiResQualities.contains(quality.trim().toUpperCase());
/// Runs the check; throws on failure. `supported == false` for formats
/// the Rust checker cannot decode (anything but FLAC and PCM WAV).
static Future<HiResCheckResult> 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);
}
/// Every sample, channel and frame must match after removing only zero-bit
/// padding or exact sample repetition. Missing/old backend support fails closed.
static Future<bool> replacementPreservesAudio(
String originalPath,
String replacementPath,
) async {
final result = await PlatformBridge.checkHiResAuthenticity(
originalPath,
options: {'verify_replacement_path': replacementPath},
);
return result['error'] == null && result['replacement_equivalent'] == true;
}
/// Keeps a unique backup on the same filesystem until [replaceAndFinalize]
/// has validated, processed, published and persisted the replacement.
/// False, cancellation or any exception restores the original atomically.
static Future<bool> withOriginalBackup(
String originalPath,
Future<bool> Function(String backupPath) replaceAndFinalize,
) async {
final original = File(originalPath);
final directory = await original.parent.createTemp('.fake-hires-');
final backup = File('${directory.path}/${original.uri.pathSegments.last}');
var moved = false;
var committed = false;
try {
await original.rename(backup.path);
moved = true;
committed = await replaceAndFinalize(backup.path);
return committed;
} finally {
if (moved && !committed) {
// If restoration fails, retain the backup and surface the error.
try {
await backup.rename(originalPath);
} on FileSystemException catch (error) {
throw HiResOriginalRestoreException(backup.path, error);
}
}
// Cleanup failure must not roll back a successfully published file.
try {
await directory.delete(recursive: true);
} on FileSystemException {
// A leftover backup is preferable to losing the completed download.
}
}
}
}
+12
View File
@@ -1156,6 +1156,18 @@ class PlatformBridge {
});
}
/// Runs the Rust Hi-Res check (spectral cutoff + padded bit depth) on
/// a FLAC or WAV file. Returns `{"supported": false}` for other formats.
static Future<Map<String, dynamic>> checkHiResAuthenticity(
String filePath, {
Map<String, dynamic>? 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
+308
View File
@@ -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<HiResCheckCard> createState() => _HiResCheckCardState();
}
class _HiResCheckCardState extends State<HiResCheckCard> {
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<void> _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<Widget> _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 = <String>[
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';
}
+7
View File
@@ -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",
+2
View File
@@ -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"
+1
View File
@@ -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
@@ -0,0 +1,492 @@
//! 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 sampled segment may have been made. They cannot prove that a
//! separate LOSSLESS copy preserves the whole file.
//!
//! - 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, a CD-derived source is "likely". This remains a
//! heuristic and does not authorize automatic replacement.
//! - 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<f64>,
/// 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<f64>,
}
/// 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<StftStats, String> {
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<f64> = (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<f64>> = 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<f64> = 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::<f64>()
/ (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<f64> = 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<f64> {
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<f64> {
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::<f64>() / 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::<f64>() / 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::<f64>() / n;
let mean_b = b.iter().sum::<f64>() / 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)
}
@@ -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<f64>,
twiddle_im: Vec<f64>,
reversed: Vec<usize>,
}
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;
}
}
}
@@ -0,0 +1,659 @@
//! 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`. These
//! confidence levels describe the sampled evidence, not permission to discard
//! audio. Replacement additionally requires a full, exact PCM comparison.
//!
//! Only FLAC and PCM WAV are decoded. Anything else is
//! [`HiResCheckError::Unsupported`], which callers treat as "check skipped".
mod evidence;
mod fft;
mod replacement;
pub use replacement::replacement_preserves_audio;
#[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<String> 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
}
/// A conservative candidate filter, not proof that a replacement is safe.
/// Both depth and rate must fit CD quality; spectral heuristics cannot
/// establish that. The caller must still compare every decoded sample.
pub fn redownload_safe(&self) -> bool {
self.is_suspicious()
&& self.confidence == CONFIDENCE_CERTAIN
&& self.declared_bit_depth > 0
&& self.effective_bit_depth > 0
&& self.effective_bit_depth <= 16
&& (self.declared_sample_rate <= 44_100
|| (self.upsampling_artifact == ARTIFACT_SAMPLE_HOLD
&& self.declared_sample_rate.is_multiple_of(44_100)))
}
/// 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<f32>,
/// 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<i32>,
}
/// 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<HiResCheckResult, HiResCheckError> {
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<String> = 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<claxon::FlacReader<BufReader<&'a mut File>>>),
Wav(WavSource<'a>),
}
impl<'a> Source<'a> {
fn open(file: &'a mut File) -> Result<Self, HiResCheckError> {
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<Window, String> {
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<BufReader<&mut File>>,
start_frame: u64,
frames: u64,
check: &dyn Fn() -> Result<(), String>,
) -> Result<Window, String> {
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<Self, HiResCheckError> {
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;
let chunk_start = file.stream_position().map_err(|e| e.to_string())?;
let chunk_end = chunk_start + size;
// Only the data chunk may use a streaming-size placeholder.
if &header[..4] != b"data" && chunk_end + pad > file_size {
return Err("truncated WAV chunk".to_string().into());
}
match &header[..4] {
b"fmt " => {
// The format prefix is at most 40 bytes. Never allocate
// from an untrusted 32-bit chunk length.
let mut chunk = [0u8; 40];
let prefix_len = size.min(chunk.len() as u64) as usize;
if prefix_len < 16 || file.read_exact(&mut chunk[..prefix_len]).is_err() {
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 {
if prefix_len < 40 || u16::from_le_bytes([chunk[16], chunk[17]]) < 22 {
return Err("truncated extensible WAV fmt chunk".to_string().into());
}
// SubFormat GUID's leading format tag.
format = u16::from_le_bytes([chunk[24], chunk[25]]);
}
file.seek(SeekFrom::Start(chunk_end + pad))
.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.
if size != u64::from(u32::MAX) && chunk_end > file_size {
return Err("truncated WAV data chunk".to_string().into());
}
data_size = size.min(file_size.saturating_sub(data_offset));
}
_ => {
file.seek(SeekFrom::Start(chunk_end + pad))
.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<Window, String> {
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)
}
}
@@ -0,0 +1,137 @@
use super::{HiResCheckError, Source};
use std::fs::File;
use std::io::{Read, Seek, SeekFrom};
/// Checks all channels and the entire duration, without a spectral heuristic
/// or floating-point tolerance. Only padding and exact sample repetition can
/// be removed. A different master, dither, resampling or truncated file fails
/// closed. Memory is bounded by one decoded block from each file.
pub fn replacement_preserves_audio(
mut original: File,
mut replacement: File,
check: &dyn Fn() -> Result<(), String>,
) -> Result<bool, HiResCheckError> {
check()?;
let original = Source::open(&mut original)?;
let replacement = Source::open(&mut replacement)?;
let rate = replacement.sample_rate();
if rate == 0
|| original.sample_rate() < rate
|| !original.sample_rate().is_multiple_of(rate)
|| original.channel_count() != replacement.channel_count()
|| original.channel_count() == 0
|| !(1..=32).contains(&original.declared_bits())
|| !(1..=32).contains(&replacement.declared_bits())
{
return Ok(false);
}
let ratio = u64::from(original.sample_rate() / rate);
let frames = replacement.total_frames();
if frames == 0 || frames.checked_mul(ratio) != Some(original.total_frames()) {
return Ok(false);
}
let mut original = PcmStream::new(original)?;
let mut replacement = PcmStream::new(replacement)?;
for index in 0..frames {
if index.is_multiple_of(4096) {
check()?;
}
let Some(expected) = replacement.next_frame()? else {
return Ok(false);
};
for _ in 0..ratio {
if original.next_frame()? != Some(expected) {
return Ok(false);
}
}
}
Ok(original.next_frame()?.is_none() && replacement.next_frame()?.is_none())
}
struct PcmStream<'a> {
source: Source<'a>,
samples: Vec<i32>,
buffer: Vec<i32>,
cursor: usize,
frames_read: u64,
channels: usize,
shift: u32,
}
impl<'a> PcmStream<'a> {
fn new(mut source: Source<'a>) -> Result<Self, HiResCheckError> {
if let Source::Wav(wav) = &mut source {
if !wav.data_size.is_multiple_of(wav.frame_bytes()) {
return Err("partial WAV audio frame".to_string().into());
}
wav.file
.seek(SeekFrom::Start(wav.data_offset))
.map_err(|e| e.to_string())?;
}
Ok(Self {
channels: source.channel_count() as usize,
shift: 32 - source.declared_bits(),
source,
samples: Vec::new(),
buffer: Vec::new(),
cursor: 0,
frames_read: 0,
})
}
fn next_frame(&mut self) -> Result<Option<&[i32]>, HiResCheckError> {
if self.cursor == self.samples.len() {
self.samples.clear();
self.cursor = 0;
match &mut self.source {
Source::Flac(reader) => {
let block = reader
.blocks()
.read_next_or_eof(std::mem::take(&mut self.buffer))
.map_err(|e| e.to_string())?;
let Some(block) = block else { return Ok(None) };
if block.channels() as usize != self.channels {
return Err("inconsistent FLAC channel count".to_string().into());
}
// Decode in stream order. Claxon's time() multiplies a
// fixed-block frame number by the current block's size,
// so it is inaccurate for a shorter final block.
for frame in 0..block.duration() as usize {
for channel in 0..self.channels {
self.samples
.push(block.channel(channel as u32)[frame] << self.shift);
}
}
self.frames_read += u64::from(block.duration());
self.buffer = block.into_buffer();
}
Source::Wav(wav) => {
let remaining = wav.data_size / wav.frame_bytes() - self.frames_read;
if remaining == 0 {
return Ok(None);
}
let frames = remaining.min(4096).min(65_536 / self.channels as u64);
let bytes_per_sample = (wav.container_bits / 8) as usize;
let mut bytes = vec![0u8; (frames * wav.frame_bytes()) as usize];
wav.file.read_exact(&mut bytes).map_err(|e| e.to_string())?;
for sample in bytes.chunks_exact(bytes_per_sample) {
let value = match bytes_per_sample {
1 => i32::from(sample[0]) - 128,
2 => i32::from(i16::from_le_bytes([sample[0], sample[1]])),
3 => i32::from_le_bytes([0, sample[0], sample[1], sample[2]]) >> 8,
_ => i32::from_le_bytes(sample.try_into().expect("32-bit PCM")),
};
self.samples.push(value << self.shift);
}
self.frames_read += frames;
}
}
}
if self.samples.is_empty() {
return Err("empty audio block".to_string().into());
}
let frame = &self.samples[self.cursor..self.cursor + self.channels];
self.cursor += self.channels;
Ok(Some(frame))
}
}
@@ -0,0 +1,920 @@
//! 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<f64> {
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<f64> {
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<f64> {
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<f64> {
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<f64> {
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<f64> {
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<i32> {
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<i32> {
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<u8>,
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<u8> {
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<u8> = 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<HiResCheckResult, HiResCheckError> {
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<f64> = (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. This supports a likely
/// verdict, but cannot prove that a separately downloaded copy is equivalent.
#[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(),
"Spectral evidence cannot authorize replacement"
);
}
/// 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<f64> = 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);
}
#[test]
fn padded_depth_does_not_make_high_rate_content_safe_to_replace_with_cd() {
let dir = TempDir::new("review-high-rate");
let samples: Vec<i32> = quantize(&full_band_noise(HIRES_FRAMES, 17), 16)
.into_iter()
.map(|sample| sample << 8)
.collect();
let result = check(&flac(&dir, "high-rate-padded.flac", &samples, HIRES_SR, 24));
assert_eq!(result.effective_bit_depth, 16);
assert!(result.cutoff_frequency_hz > 30_000.0);
assert!(
!result.redownload_safe(),
"Full-rate content would be lost: {result:?}"
);
}
#[test]
fn rate_upsampling_does_not_make_real_24_bit_depth_safe_to_replace_with_cd() {
let dir = TempDir::new("review-full-depth");
let source = pcm_using_bits(24, 24, CD_SOURCE_FRAMES, 1);
let samples: Vec<i32> = source.into_iter().flat_map(|sample| [sample; 4]).collect();
let result = check(&flac(
&dir,
"full-depth-upsampled.flac",
&samples,
HIRES_SR,
24,
));
assert_eq!(result.effective_bit_depth, 24);
assert_eq!(result.upsampling_artifact, ARTIFACT_SAMPLE_HOLD);
assert!(
!result.redownload_safe(),
"Real 24-bit depth would be lost: {result:?}"
);
}
fn preserves(original: &Path, replacement: &Path) -> bool {
replacement_preserves_audio(
File::open(original).expect("original"),
File::open(replacement).expect("replacement"),
&|| Ok(()),
)
.unwrap_or(false)
}
#[test]
fn replacement_verifies_padding_and_sample_repetition_across_every_frame() {
let dir = TempDir::new("replacement-pcm");
let source = pcm_using_bits(16, 16, 20_001, 8);
let replacement = flac(&dir, "cd.flac", &source, CD_SR, 16);
for ratio in [1, 2, 4] {
let padded: Vec<i32> = source
.iter()
.flat_map(|v| std::iter::repeat_n(v << 8, ratio))
.collect();
let original = flac(&dir, "original.flac", &padded, CD_SR * ratio as u32, 24);
assert!(preserves(&original, &replacement));
let wav = dir.file("original.wav");
write_wav(&wav, &padded, CD_SR * ratio as u32, 24);
assert!(preserves(&wav, &replacement));
}
}
#[test]
fn replacement_rejects_real_precision_outside_the_sampled_window() {
let dir = TempDir::new("replacement-tail");
let source = pcm_using_bits(16, 16, 20_001, 9);
let replacement = flac(&dir, "cd.flac", &source, CD_SR, 16);
let padded: Vec<i32> = source.iter().map(|v| v << 8).collect();
for index in [0, 10_000, 20_000] {
let mut original = padded.clone();
original[index] += 1;
let original = flac(&dir, "original.flac", &original, CD_SR, 24);
assert!(!preserves(&original, &replacement), "frame {index}");
}
}
#[test]
fn replacement_rejects_changed_master_rate_duration_and_truncation() {
let dir = TempDir::new("replacement-invalid");
let source = pcm_using_bits(16, 16, 20_001, 10);
let padded: Vec<i32> = source.iter().map(|v| v << 8).collect();
let original = flac(&dir, "original.flac", &padded, CD_SR, 24);
let other = pcm_using_bits(16, 16, source.len(), 11);
for (samples, rate) in [
(&other[..], CD_SR),
(&source[..], 48_000),
(&source[..20_000], CD_SR),
] {
let replacement = flac(&dir, "other.flac", samples, rate, 16);
assert!(!preserves(&original, &replacement));
}
let replacement = flac(&dir, "truncated.flac", &source, CD_SR, 16);
let file = std::fs::OpenOptions::new()
.write(true)
.open(&replacement)
.expect("open");
file.set_len(file.metadata().expect("stat").len() - 3)
.expect("truncate");
assert!(!preserves(&original, &replacement));
}
#[test]
fn replacement_checks_every_channel() {
let dir = TempDir::new("replacement-stereo");
let source = pcm_using_bits(16, 16, 20_001, 12);
let padded: Vec<i32> = source.iter().map(|v| v << 8).collect();
let original = dir.file("stereo.flac");
write_flac(&original, &padded, CD_SR, 24, 2);
let stereo = dir.file("stereo.wav");
let interleaved: Vec<i32> = source.iter().flat_map(|&v| [v, v]).collect();
write_wav(&stereo, &interleaved, CD_SR, 16);
let mut bytes = std::fs::read(&stereo).expect("wav");
bytes[22..24].copy_from_slice(&2u16.to_le_bytes());
bytes[28..32].copy_from_slice(&(CD_SR * 4).to_le_bytes());
bytes[32..34].copy_from_slice(&4u16.to_le_bytes());
std::fs::write(&stereo, &bytes).expect("stereo header");
assert!(preserves(&original, &stereo));
// Only the last frame's right channel changes.
let last = bytes.len() - 2;
bytes[last] ^= 1;
std::fs::write(&stereo, &bytes).expect("different right channel");
assert!(!preserves(&original, &stereo));
let mono = flac(&dir, "mono.flac", &source, CD_SR, 16);
assert!(!preserves(&original, &mono));
}
#[test]
fn replacement_comparison_is_cancellable() {
let dir = TempDir::new("replacement-cancel");
let original = flac(&dir, "original.flac", &vec![256; 20_000], CD_SR, 24);
let replacement = flac(&dir, "cd.flac", &vec![1; 20_000], CD_SR, 16);
let calls = std::cell::Cell::new(0);
let result = replacement_preserves_audio(
File::open(original).expect("original"),
File::open(replacement).expect("replacement"),
&|| {
calls.set(calls.get() + 1);
if calls.get() > 2 {
Err("cancelled".into())
} else {
Ok(())
}
},
);
assert!(matches!(result, Err(HiResCheckError::Failed(message)) if message == "cancelled"));
}
#[test]
fn wav_rejects_oversized_and_truncated_chunks_without_allocating_them() {
let dir = TempDir::new("wav-malformed");
let path = dir.file("bad.wav");
for id in [b"fmt ", b"JUNK"] {
let mut bytes = b"RIFF\xff\xff\xff\xffWAVE".to_vec();
bytes.extend_from_slice(id);
bytes.extend_from_slice(&u32::MAX.to_le_bytes());
bytes.extend_from_slice(&[0; 40]);
std::fs::write(&path, bytes).expect("malformed WAV");
assert!(run_check(&path, &HiResCheckOptions::default()).is_err());
}
write_wav(&path, &[0; 100], CD_SR, 16);
let mut bytes = std::fs::read(&path).expect("wav");
bytes.truncate(bytes.len() - 2);
std::fs::write(&path, bytes).expect("truncated data");
assert!(run_check(&path, &HiResCheckOptions::default()).is_err());
}
#[test]
fn wav_skips_extra_format_bytes_and_odd_chunk_padding() {
let dir = TempDir::new("wav-extra-fmt");
let path = dir.file("extended.wav");
write_wav(&path, &pcm_using_bits(16, 24, 20_000, 13), CD_SR, 24);
let mut bytes = std::fs::read(&path).expect("wav");
bytes[16..20].copy_from_slice(&41u32.to_le_bytes());
bytes.splice(36..36, [0; 26]); // 25 extra format bytes plus one pad byte
let len = bytes.len() as u32 - 8;
bytes[4..8].copy_from_slice(&len.to_le_bytes());
std::fs::write(&path, bytes).expect("extra fmt bytes");
assert!(check(&path).padded_bit_depth());
}
@@ -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};
+145
View File
@@ -0,0 +1,145 @@
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<String> 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.
/// With `verify_replacement_path`, bypasses sampled analysis and compares the
/// entire decoded PCM stream, returning only `replacement_equivalent`.
#[uniffi::export]
pub fn check_hires_authenticity(
path: String,
options_json: String,
lease: Option<Arc<RequestLease>>,
) -> Result<String, HiResCheckError> {
let check = || check_lease(lease.as_deref());
check()?;
let request: serde_json::Value = if options_json.trim().is_empty() {
serde_json::json!({})
} else {
serde_json::from_str(&options_json)
.map_err(|error| format!("invalid hi-res check options: {error}"))?
};
if let Some(replacement_path) = request.get("verify_replacement_path") {
let replacement_path = replacement_path
.as_str()
.filter(|path| !path.trim().is_empty())
.ok_or("invalid replacement path".to_string())?;
let equivalent = hires::replacement_preserves_audio(
open_audio_file(&path)?,
open_audio_file(replacement_path)?,
&check,
)
.map_err(|error| error.to_string())?;
return Ok(serde_json::json!({"replacement_equivalent": equivalent}).to_string());
}
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 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 replacement_verification_is_exposed_and_fails_closed() {
let dir =
std::env::temp_dir().join(format!("spotiflac-hires-verify-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("dir");
let original = dir.join("original.wav");
let replacement = dir.join("replacement.wav");
let wav = |bits: u16, sample: i32| {
let bytes = u32::from(bits / 8);
let mut data = b"RIFF".to_vec();
data.extend_from_slice(&(36 + bytes).to_le_bytes());
data.extend_from_slice(b"WAVEfmt ");
data.extend_from_slice(&16u32.to_le_bytes());
data.extend_from_slice(&1u16.to_le_bytes());
data.extend_from_slice(&1u16.to_le_bytes());
data.extend_from_slice(&44_100u32.to_le_bytes());
data.extend_from_slice(&(44_100 * bytes).to_le_bytes());
data.extend_from_slice(&(bytes as u16).to_le_bytes());
data.extend_from_slice(&bits.to_le_bytes());
data.extend_from_slice(b"data");
data.extend_from_slice(&bytes.to_le_bytes());
data.extend_from_slice(&sample.to_le_bytes()[..bytes as usize]);
data
};
std::fs::write(&original, wav(24, 256)).expect("original");
for (sample, expected) in [(1, true), (2, false)] {
std::fs::write(&replacement, wav(16, sample)).expect("replacement");
let raw = check_hires_authenticity(
original.to_string_lossy().into_owned(),
serde_json::json!({"verify_replacement_path": replacement}).to_string(),
None,
)
.expect("verify");
let value: serde_json::Value = serde_json::from_str(&raw).expect("json");
assert_eq!(value["replacement_equivalent"], expected);
assert!(value.get("redownload_safe").is_none());
}
assert!(
check_hires_authenticity(
original.to_string_lossy().into_owned(),
"{\"verify_replacement_path\":123}".into(),
None,
)
.is_err()
);
let _ = std::fs::remove_dir_all(dir);
}
#[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);
}
}
+1
View File
@@ -4,6 +4,7 @@ mod cancellation;
mod extensions;
mod ffmpeg;
mod filename;
mod hires;
mod index;
mod logging;
mod lyrics;
+2 -2
View File
@@ -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<File, String> {
pub(crate) fn open_audio_file(path: &str) -> Result<File, String> {
let mut options = OpenOptions::new();
options.read(true);
#[cfg(unix)]
+136
View File
@@ -0,0 +1,136 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/services/hires_check_service.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const channel = MethodChannel('com.zarz.spotiflac/backend');
late Directory directory;
late File original;
setUp(() async {
directory = await Directory.systemTemp.createTemp('hires-replacement-');
original = await File(
'${directory.path}/track.flac',
).writeAsString('original audio');
});
tearDown(() async {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, null);
await directory.delete(recursive: true);
});
test('automatic replacement defaults off for new and existing settings', () {
expect(const AppSettings().redownloadFakeHiRes, isFalse);
expect(AppSettings.fromJson({}).redownloadFakeHiRes, isFalse);
final enabled = const AppSettings().copyWith(redownloadFakeHiRes: true);
expect(AppSettings.fromJson(enabled.toJson()).redownloadFakeHiRes, isTrue);
});
test(
'finalization exception restores original over a partial replacement',
() async {
await expectLater(
HiResCheckService.withOriginalBackup(original.path, (backup) async {
expect(await File(backup).readAsString(), 'original audio');
await original.writeAsString('incomplete replacement');
throw StateError('finalization failed');
}),
throwsStateError,
);
expect(await original.readAsString(), 'original audio');
expect(await directory.list().length, 1);
},
);
test('pause or cancellation restores the original', () async {
final success = await HiResCheckService.withOriginalBackup(original.path, (
backup,
) async {
await original.writeAsString('partial');
return false;
});
expect(success, isFalse);
expect(await original.readAsString(), 'original audio');
});
test(
'backup survives until final publication and persistence complete',
() async {
final pending = Completer<bool>();
final started = Completer<String>();
final transaction = HiResCheckService.withOriginalBackup(original.path, (
backup,
) async {
await original.writeAsString('verified replacement');
started.complete(backup);
return pending.future;
});
final backup = await started.future;
expect(await File(backup).readAsString(), 'original audio');
pending.complete(true);
expect(await transaction, isTrue);
expect(await original.readAsString(), 'verified replacement');
expect(await File(backup).exists(), isFalse);
},
);
test('existing backup is never overwritten', () async {
final oldBackup = await File(
'${original.path}.fake-hires.bak',
).writeAsString('older original');
await HiResCheckService.withOriginalBackup(
original.path,
(_) async => false,
);
expect(await oldBackup.readAsString(), 'older original');
expect(await original.readAsString(), 'original audio');
});
test('failed restoration retains backup and reports its location', () async {
String? backupPath;
await expectLater(
HiResCheckService.withOriginalBackup(original.path, (backup) async {
backupPath = backup;
await Directory(original.path).create();
return false;
}),
throwsA(isA<HiResOriginalRestoreException>()),
);
expect(await File(backupPath!).readAsString(), 'original audio');
});
test(
'verification requires explicit full-file success from the backend',
() async {
for (final reply in [
{'supported': true, 'redownload_safe': true},
{'replacement_equivalent': false},
{'error': 'decode failed', 'replacement_equivalent': true},
{'replacement_equivalent': true},
]) {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async {
expect(call.method, 'checkHiResAuthenticity');
final arguments = call.arguments as Map<Object?, Object?>;
expect(arguments['file_path'], 'original.flac');
expect(jsonDecode(arguments['options_json'] as String), {
'verify_replacement_path': 'replacement.flac',
});
return jsonEncode(reply);
});
expect(
await HiResCheckService.replacementPreservesAudio(
'original.flac',
'replacement.flac',
),
reply['replacement_equivalent'] == true && reply['error'] == null,
);
}
},
);
}