mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-02 22:26:52 +02:00
Merge remote-tracking branch 'origin/main'
This commit is contained in:
commit
a470ed8d7c
36 files changed
+3690
-12
No files matched your search
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) ...[
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
Generated
+7
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ mod cancellation;
|
||||
mod extensions;
|
||||
mod ffmpeg;
|
||||
mod filename;
|
||||
mod hires;
|
||||
mod index;
|
||||
mod logging;
|
||||
mod lyrics;
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user