Merge remote-tracking branch 'origin/main'

This commit is contained in:
zarzet committed 2026-09-25 01:03:00 +07:00
commit a470ed8d7c
36 files changed
+3690 -12

No files matched your search

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