mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-29 12:52:13 +02:00
Add Hi-Res authenticity check
Introduce a per-file Hi-Res authenticity checker and optional auto re-download for flagged fake Hi-Res downloads. Adds Rust checker modules (fft, evidence, FLAC/WAV decoding, tests) and a mobile API (check_hires_authenticity). Wire platform bindings for Android/iOS and PlatformBridge. Add Dart HiResCheckService, UI HiResCheckCard, settings toggle (redownloadFakeHiRes) and integration in the download pipeline to quarantine and replace suspicious files with LOSSLESS when safe. Adds claxon FLAC dependency.
This commit is contained in:
@@ -4856,6 +4856,131 @@
|
||||
"@audioAnalysisRescan": {
|
||||
"description": "Tooltip/label for the button that re-runs the audio analysis, discarding cached results"
|
||||
},
|
||||
"downloadRedownloadFakeHiRes": "Re-download fake Hi-Res",
|
||||
"@downloadRedownloadFakeHiRes": {
|
||||
"description": "Setting title: automatically replace downloads that measure as fake Hi-Res"
|
||||
},
|
||||
"downloadRedownloadFakeHiResSubtitle": "Check each Hi-Res FLAC after download and download it again as Lossless when it is certainly or very likely fake. Possible filtered masters are kept",
|
||||
"@downloadRedownloadFakeHiResSubtitle": {
|
||||
"description": "Setting subtitle for the fake Hi-Res re-download toggle"
|
||||
},
|
||||
"hiResCheckVerdictFakeCertain": "Fake Hi-Res",
|
||||
"@hiResCheckVerdictFakeCertain": {
|
||||
"description": "Verdict: an exact fingerprint (padded bits or upsampling pattern) proves the file is not real Hi-Res"
|
||||
},
|
||||
"hiResCheckVerdictFakeSuspect": "Possibly a filtered master",
|
||||
"@hiResCheckVerdictFakeSuspect": {
|
||||
"description": "Verdict: fails the cutoff test but may be a genuine master low-pass filtered in mastering"
|
||||
},
|
||||
"hiResCheckReasonSampleHold": "Every sample is repeated: upsampled by duplication",
|
||||
"@hiResCheckReasonSampleHold": {
|
||||
"description": "Reason: sample-and-hold upsampling detected"
|
||||
},
|
||||
"hiResCheckReasonInterpolation": "In-between samples are linearly interpolated",
|
||||
"@hiResCheckReasonInterpolation": {
|
||||
"description": "Reason: linear-interpolation upsampling detected"
|
||||
},
|
||||
"hiResCheckReasonImaging": "Content above 22 kHz mirrors the audible band",
|
||||
"@hiResCheckReasonImaging": {
|
||||
"description": "Reason: spectral imaging from upsampling detected"
|
||||
},
|
||||
"hiResCheckMusicContent": "Musical content",
|
||||
"@hiResCheckMusicContent": {
|
||||
"description": "Label for the highest frequency that still moves with the music"
|
||||
},
|
||||
"hiResCheckUltrasonicNoise": "Above {cutoff} only steady noise (typical of DSD or analog tape transfers): a {rate} file would hold all the music",
|
||||
"@hiResCheckUltrasonicNoise": {
|
||||
"description": "Shown when the ultrasonic content of a Hi-Res file is noise rather than music",
|
||||
"placeholders": {
|
||||
"cutoff": {
|
||||
"type": "String"
|
||||
},
|
||||
"rate": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"hiResCheckTitle": "Hi-Res Authenticity",
|
||||
"@hiResCheckTitle": {
|
||||
"description": "Title of the fake Hi-Res check card"
|
||||
},
|
||||
"hiResCheckDescription": "Detect upsampled or bit-padded fake Hi-Res files",
|
||||
"@hiResCheckDescription": {
|
||||
"description": "Explains what the Hi-Res authenticity check does"
|
||||
},
|
||||
"hiResCheckRun": "Check",
|
||||
"@hiResCheckRun": {
|
||||
"description": "Button that starts the Hi-Res authenticity check"
|
||||
},
|
||||
"hiResCheckChecking": "Checking Hi-Res authenticity...",
|
||||
"@hiResCheckChecking": {
|
||||
"description": "Loading text while the Hi-Res check runs"
|
||||
},
|
||||
"hiResCheckVerdictFake": "Likely fake Hi-Res",
|
||||
"@hiResCheckVerdictFake": {
|
||||
"description": "Verdict: the file claims Hi-Res but does not measure as it"
|
||||
},
|
||||
"hiResCheckVerdictGenuine": "Measures as genuine Hi-Res",
|
||||
"@hiResCheckVerdictGenuine": {
|
||||
"description": "Verdict: no upsampling or padded bit depth found"
|
||||
},
|
||||
"hiResCheckVerdictStandard": "Standard definition, nothing to flag",
|
||||
"@hiResCheckVerdictStandard": {
|
||||
"description": "Verdict: neither sample rate nor bit depth claims Hi-Res"
|
||||
},
|
||||
"hiResCheckVerdictInconclusive": "Inconclusive: the analyzed segment is too quiet or short",
|
||||
"@hiResCheckVerdictInconclusive": {
|
||||
"description": "Verdict: analysis could not reach a reliable conclusion"
|
||||
},
|
||||
"hiResCheckUnsupported": "Only FLAC and WAV files can be checked",
|
||||
"@hiResCheckUnsupported": {
|
||||
"description": "Shown when the file format cannot be decoded by the Hi-Res check"
|
||||
},
|
||||
"hiResCheckCutoff": "Content cutoff",
|
||||
"@hiResCheckCutoff": {
|
||||
"description": "Label for the highest frequency with real content"
|
||||
},
|
||||
"hiResCheckBitsInUse": "Bits in use",
|
||||
"@hiResCheckBitsInUse": {
|
||||
"description": "Label for effective vs declared bit depth"
|
||||
},
|
||||
"hiResCheckDisclaimer": "The spectrum stops early, but nothing proves it was upsampled: it may be a genuine master low-pass filtered in mastering. It is not replaced automatically.",
|
||||
"@hiResCheckDisclaimer": {
|
||||
"description": "Caveat shown under a fake Hi-Res verdict"
|
||||
},
|
||||
"hiResCheckFailed": "Hi-Res check failed: {error}",
|
||||
"@hiResCheckFailed": {
|
||||
"description": "Error shown when the Hi-Res check fails",
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"hiResCheckReasonRate": "Declares {sampleRate} but content stops at ~{cutoff}",
|
||||
"@hiResCheckReasonRate": {
|
||||
"description": "Reason for a fake verdict caused by upsampling",
|
||||
"placeholders": {
|
||||
"sampleRate": {
|
||||
"type": "String"
|
||||
},
|
||||
"cutoff": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"hiResCheckReasonDepth": "Declares {declared}-bit but only {effective} bits carry data",
|
||||
"@hiResCheckReasonDepth": {
|
||||
"description": "Reason for a fake verdict caused by padded bit depth",
|
||||
"placeholders": {
|
||||
"declared": {
|
||||
"type": "int"
|
||||
},
|
||||
"effective": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"audioAnalysisRescanning": "Re-analyzing audio...",
|
||||
"@audioAnalysisRescanning": {
|
||||
"description": "Loading text while audio is being re-analyzed after an explicit refresh"
|
||||
|
||||
@@ -4732,6 +4732,131 @@
|
||||
"@audioAnalysisRescan": {
|
||||
"description": "Tooltip/label for the button that re-runs the audio analysis, discarding cached results"
|
||||
},
|
||||
"downloadRedownloadFakeHiRes": "Riscarica i falsi Hi-Res",
|
||||
"@downloadRedownloadFakeHiRes": {
|
||||
"description": "Setting title: automatically replace downloads that measure as fake Hi-Res"
|
||||
},
|
||||
"downloadRedownloadFakeHiResSubtitle": "Verifica ogni FLAC Hi-Res dopo il download e lo riscarica in Lossless quando è falso con certezza o quasi. I possibili master filtrati vengono tenuti",
|
||||
"@downloadRedownloadFakeHiResSubtitle": {
|
||||
"description": "Setting subtitle for the fake Hi-Res re-download toggle"
|
||||
},
|
||||
"hiResCheckVerdictFakeCertain": "Falso Hi-Res",
|
||||
"@hiResCheckVerdictFakeCertain": {
|
||||
"description": "Verdict: an exact fingerprint (padded bits or upsampling pattern) proves the file is not real Hi-Res"
|
||||
},
|
||||
"hiResCheckVerdictFakeSuspect": "Forse un master filtrato",
|
||||
"@hiResCheckVerdictFakeSuspect": {
|
||||
"description": "Verdict: fails the cutoff test but may be a genuine master low-pass filtered in mastering"
|
||||
},
|
||||
"hiResCheckReasonSampleHold": "Ogni campione è ripetuto: ricampionato per duplicazione",
|
||||
"@hiResCheckReasonSampleHold": {
|
||||
"description": "Reason: sample-and-hold upsampling detected"
|
||||
},
|
||||
"hiResCheckReasonInterpolation": "I campioni intermedi sono interpolati linearmente",
|
||||
"@hiResCheckReasonInterpolation": {
|
||||
"description": "Reason: linear-interpolation upsampling detected"
|
||||
},
|
||||
"hiResCheckReasonImaging": "Il contenuto sopra i 22 kHz è lo specchio della banda udibile",
|
||||
"@hiResCheckReasonImaging": {
|
||||
"description": "Reason: spectral imaging from upsampling detected"
|
||||
},
|
||||
"hiResCheckMusicContent": "Contenuto musicale",
|
||||
"@hiResCheckMusicContent": {
|
||||
"description": "Label for the highest frequency that still moves with the music"
|
||||
},
|
||||
"hiResCheckUltrasonicNoise": "Sopra {cutoff} solo rumore costante (tipico dei trasferimenti da DSD o nastro analogico): un file a {rate} conterrebbe tutta la musica",
|
||||
"@hiResCheckUltrasonicNoise": {
|
||||
"description": "Shown when the ultrasonic content of a Hi-Res file is noise rather than music",
|
||||
"placeholders": {
|
||||
"cutoff": {
|
||||
"type": "String"
|
||||
},
|
||||
"rate": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"hiResCheckTitle": "Autenticità Hi-Res",
|
||||
"@hiResCheckTitle": {
|
||||
"description": "Title of the fake Hi-Res check card"
|
||||
},
|
||||
"hiResCheckDescription": "Rileva file Hi-Res falsi, ricampionati o con bit riempiti",
|
||||
"@hiResCheckDescription": {
|
||||
"description": "Explains what the Hi-Res authenticity check does"
|
||||
},
|
||||
"hiResCheckRun": "Verifica",
|
||||
"@hiResCheckRun": {
|
||||
"description": "Button that starts the Hi-Res authenticity check"
|
||||
},
|
||||
"hiResCheckChecking": "Verifica dell'autenticità Hi-Res...",
|
||||
"@hiResCheckChecking": {
|
||||
"description": "Loading text while the Hi-Res check runs"
|
||||
},
|
||||
"hiResCheckVerdictFake": "Probabile falso Hi-Res",
|
||||
"@hiResCheckVerdictFake": {
|
||||
"description": "Verdict: the file claims Hi-Res but does not measure as it"
|
||||
},
|
||||
"hiResCheckVerdictGenuine": "Risulta Hi-Res autentico",
|
||||
"@hiResCheckVerdictGenuine": {
|
||||
"description": "Verdict: no upsampling or padded bit depth found"
|
||||
},
|
||||
"hiResCheckVerdictStandard": "Definizione standard, niente da segnalare",
|
||||
"@hiResCheckVerdictStandard": {
|
||||
"description": "Verdict: neither sample rate nor bit depth claims Hi-Res"
|
||||
},
|
||||
"hiResCheckVerdictInconclusive": "Inconcludente: il segmento analizzato è troppo silenzioso o breve",
|
||||
"@hiResCheckVerdictInconclusive": {
|
||||
"description": "Verdict: analysis could not reach a reliable conclusion"
|
||||
},
|
||||
"hiResCheckUnsupported": "Si possono verificare solo file FLAC e WAV",
|
||||
"@hiResCheckUnsupported": {
|
||||
"description": "Shown when the file format cannot be decoded by the Hi-Res check"
|
||||
},
|
||||
"hiResCheckCutoff": "Taglio del contenuto",
|
||||
"@hiResCheckCutoff": {
|
||||
"description": "Label for the highest frequency with real content"
|
||||
},
|
||||
"hiResCheckBitsInUse": "Bit usati",
|
||||
"@hiResCheckBitsInUse": {
|
||||
"description": "Label for effective vs declared bit depth"
|
||||
},
|
||||
"hiResCheckDisclaimer": "Lo spettro si ferma presto, ma nulla prova che sia ricampionato: potrebbe essere un master autentico filtrato in mastering. Non viene sostituito automaticamente.",
|
||||
"@hiResCheckDisclaimer": {
|
||||
"description": "Caveat shown under a fake Hi-Res verdict"
|
||||
},
|
||||
"hiResCheckFailed": "Verifica Hi-Res non riuscita: {error}",
|
||||
"@hiResCheckFailed": {
|
||||
"description": "Error shown when the Hi-Res check fails",
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"hiResCheckReasonRate": "Dichiara {sampleRate} ma il contenuto si ferma a ~{cutoff}",
|
||||
"@hiResCheckReasonRate": {
|
||||
"description": "Reason for a fake verdict caused by upsampling",
|
||||
"placeholders": {
|
||||
"sampleRate": {
|
||||
"type": "String"
|
||||
},
|
||||
"cutoff": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"hiResCheckReasonDepth": "Dichiara {declared} bit ma solo {effective} contengono dati",
|
||||
"@hiResCheckReasonDepth": {
|
||||
"description": "Reason for a fake verdict caused by padded bit depth",
|
||||
"placeholders": {
|
||||
"declared": {
|
||||
"type": "int"
|
||||
},
|
||||
"effective": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"audioAnalysisRescanning": "Re-analyzing audio...",
|
||||
"@audioAnalysisRescanning": {
|
||||
"description": "Loading text while audio is being re-analyzed after an explicit refresh"
|
||||
|
||||
@@ -77,6 +77,8 @@ class AppSettings {
|
||||
final String locale;
|
||||
final String lyricsMode;
|
||||
final bool autoConvertDownloads;
|
||||
// Re-download a Hi-Res request at LOSSLESS when the file measures as fake.
|
||||
final bool redownloadFakeHiRes;
|
||||
final String autoConvertFormat; // 'mp3', 'aac' (M4A), or 'opus'
|
||||
final String autoConvertBitrate; // '128k', '192k', '256k', or '320k'
|
||||
final bool
|
||||
@@ -172,6 +174,7 @@ class AppSettings {
|
||||
this.locale = 'system',
|
||||
this.lyricsMode = 'embed',
|
||||
this.autoConvertDownloads = false,
|
||||
this.redownloadFakeHiRes = false,
|
||||
this.autoConvertFormat = 'mp3',
|
||||
this.autoConvertBitrate = '320k',
|
||||
this.useAllFilesAccess = false,
|
||||
@@ -260,6 +263,7 @@ class AppSettings {
|
||||
String? locale,
|
||||
String? lyricsMode,
|
||||
bool? autoConvertDownloads,
|
||||
bool? redownloadFakeHiRes,
|
||||
String? autoConvertFormat,
|
||||
String? autoConvertBitrate,
|
||||
bool? useAllFilesAccess,
|
||||
@@ -353,6 +357,7 @@ class AppSettings {
|
||||
locale: locale ?? this.locale,
|
||||
lyricsMode: lyricsMode ?? this.lyricsMode,
|
||||
autoConvertDownloads: autoConvertDownloads ?? this.autoConvertDownloads,
|
||||
redownloadFakeHiRes: redownloadFakeHiRes ?? this.redownloadFakeHiRes,
|
||||
autoConvertFormat: autoConvertFormat ?? this.autoConvertFormat,
|
||||
autoConvertBitrate: autoConvertBitrate ?? this.autoConvertBitrate,
|
||||
useAllFilesAccess: useAllFilesAccess ?? this.useAllFilesAccess,
|
||||
|
||||
@@ -63,6 +63,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
||||
locale: json['locale'] as String? ?? 'system',
|
||||
lyricsMode: json['lyricsMode'] as String? ?? 'embed',
|
||||
autoConvertDownloads: json['autoConvertDownloads'] as bool? ?? false,
|
||||
redownloadFakeHiRes: json['redownloadFakeHiRes'] as bool? ?? false,
|
||||
autoConvertFormat: json['autoConvertFormat'] as String? ?? 'mp3',
|
||||
autoConvertBitrate: json['autoConvertBitrate'] as String? ?? '320k',
|
||||
useAllFilesAccess: json['useAllFilesAccess'] as bool? ?? false,
|
||||
@@ -152,6 +153,7 @@ Map<String, dynamic> _$AppSettingsToJson(
|
||||
'locale': instance.locale,
|
||||
'lyricsMode': instance.lyricsMode,
|
||||
'autoConvertDownloads': instance.autoConvertDownloads,
|
||||
'redownloadFakeHiRes': instance.redownloadFakeHiRes,
|
||||
'autoConvertFormat': instance.autoConvertFormat,
|
||||
'autoConvertBitrate': instance.autoConvertBitrate,
|
||||
'useAllFilesAccess': instance.useAllFilesAccess,
|
||||
|
||||
@@ -25,6 +25,7 @@ import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/services/download_request_payload.dart';
|
||||
import 'package:spotiflac_android/services/download_motion_artwork_source.dart';
|
||||
import 'package:spotiflac_android/services/ffmpeg_service.dart';
|
||||
import 'package:spotiflac_android/services/hires_check_service.dart';
|
||||
import 'package:spotiflac_android/services/replaygain_service.dart';
|
||||
import 'package:spotiflac_android/services/notification_service.dart';
|
||||
import 'package:spotiflac_android/services/verification_notification.dart';
|
||||
@@ -57,6 +58,7 @@ part 'download_queue_provider_finalization.dart';
|
||||
part 'download_queue_provider_replaygain.dart';
|
||||
part 'download_queue_provider_embedding.dart';
|
||||
part 'download_queue_provider_single_item.dart';
|
||||
part 'download_queue_provider_hires_check.dart';
|
||||
|
||||
final _log = AppLogger('DownloadQueue');
|
||||
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
part of 'download_queue_provider.dart';
|
||||
|
||||
// Per-track fake Hi-Res check (AppSettings.redownloadFakeHiRes), ported from
|
||||
// SpotiFLAC-Module-Version's --redownload-fake-hires.
|
||||
//
|
||||
// Runs inside one _DownloadRun, right after the file is decoded and before
|
||||
// anything is published, tagged or converted: the verdict decides which file
|
||||
// the rest of the pipeline works on. A flagged file is set aside, the same
|
||||
// track is downloaded again at LOSSLESS, and only then is the flagged file
|
||||
// deleted. If the replacement fails the original is put back unchanged.
|
||||
|
||||
enum _FakeHiResOutcome {
|
||||
/// Nothing to do, or the replacement failed: finish with the current file.
|
||||
kept,
|
||||
|
||||
/// `result` now holds the LOSSLESS replacement; finalize that instead.
|
||||
replaced,
|
||||
|
||||
/// The item was cancelled or paused mid-replacement; stop the run.
|
||||
aborted,
|
||||
}
|
||||
|
||||
/// Everything a replacement attempt may overwrite, so a failed one can put
|
||||
/// the run back exactly as it was.
|
||||
class _FakeHiResRunState {
|
||||
_FakeHiResRunState(_DownloadRun run)
|
||||
: result = run.result,
|
||||
quality = run.quality,
|
||||
safOutputExt = run.safOutputExt,
|
||||
safFileName = run.safFileName,
|
||||
safBaseName = run.safBaseName,
|
||||
finalSafFileName = run.finalSafFileName,
|
||||
effectiveSafMode = run.effectiveSafMode,
|
||||
effectiveOutputDir = run.effectiveOutputDir,
|
||||
trackToDownload = run.trackToDownload;
|
||||
|
||||
final Map<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;
|
||||
|
||||
void restore(_DownloadRun run) {
|
||||
run.result = result;
|
||||
run.quality = quality;
|
||||
run.safOutputExt = safOutputExt;
|
||||
run.safFileName = safFileName;
|
||||
run.safBaseName = safBaseName;
|
||||
run.finalSafFileName = finalSafFileName;
|
||||
run.effectiveSafMode = effectiveSafMode;
|
||||
run.effectiveOutputDir = effectiveOutputDir;
|
||||
run.trackToDownload = trackToDownload;
|
||||
}
|
||||
}
|
||||
|
||||
extension _DownloadRunFakeHiRes on _DownloadRun {
|
||||
/// [requestTrack] is the track as it was sent to the backend, before the
|
||||
/// first result was merged into it: the replacement gets the same brief
|
||||
/// the first attempt did, not the first attempt's conclusions.
|
||||
Future<_FakeHiResOutcome> _replaceFakeHiResIfNeeded(
|
||||
Track requestTrack,
|
||||
) async {
|
||||
final path = filePath;
|
||||
if (!settings.redownloadFakeHiRes ||
|
||||
wasExisting ||
|
||||
path == null ||
|
||||
!HiResCheckService.isHiResQuality(quality)) {
|
||||
return _FakeHiResOutcome.kept;
|
||||
}
|
||||
if (isContentUri(path)) {
|
||||
// Already published to SAF: there is no local file to set aside, and
|
||||
// a heuristic is not worth deleting the only copy for.
|
||||
_log.d('[hires-check] skipped: output already published to SAF');
|
||||
return _FakeHiResOutcome.kept;
|
||||
}
|
||||
if (!_serviceOffersFakeHiResFallback()) {
|
||||
_log.d(
|
||||
'[hires-check] skipped: ${item.service} has no '
|
||||
'${HiResCheckService.fakeHiResFallbackQuality} quality',
|
||||
);
|
||||
return _FakeHiResOutcome.kept;
|
||||
}
|
||||
|
||||
final HiResCheckResult check;
|
||||
try {
|
||||
check = await HiResCheckService.check(path);
|
||||
} catch (e) {
|
||||
// Never a reason to fail a finished download.
|
||||
_log.w('[hires-check] skipped for ${item.track.name}: $e');
|
||||
return _FakeHiResOutcome.kept;
|
||||
}
|
||||
if (!check.supported || !check.isFake) {
|
||||
_log.d(
|
||||
'[hires-check] ${item.track.name}: '
|
||||
'${check.supported ? check.verdict : 'unsupported format'}',
|
||||
);
|
||||
return _FakeHiResOutcome.kept;
|
||||
}
|
||||
if (!check.redownloadSafe) {
|
||||
// A suspect may be a genuine master low-pass filtered in mastering;
|
||||
// a LOSSLESS copy could lose bit depth it really has. Report only.
|
||||
_log.w(
|
||||
'[hires-check] ${item.track.name} may be fake Hi-Res '
|
||||
'(${check.reason}); kept, not certain enough to replace',
|
||||
);
|
||||
return _FakeHiResOutcome.kept;
|
||||
}
|
||||
|
||||
_log.w(
|
||||
'[hires-check] ${item.track.name} looks like fake Hi-Res '
|
||||
'(${check.reason}); re-downloading as '
|
||||
'${HiResCheckService.fakeHiResFallbackQuality}',
|
||||
);
|
||||
|
||||
final quarantined = '$path${HiResCheckService.quarantineSuffix}';
|
||||
try {
|
||||
await File(path).rename(quarantined);
|
||||
} catch (e) {
|
||||
_log.w('[hires-check] could not set aside $path, keeping it: $e');
|
||||
return _FakeHiResOutcome.kept;
|
||||
}
|
||||
|
||||
final saved = _FakeHiResRunState(this);
|
||||
var keepQuarantine = false;
|
||||
try {
|
||||
trackToDownload = requestTrack;
|
||||
await _useFakeHiResFallbackQuality();
|
||||
n.updateItemStatus(item.id, DownloadStatus.downloading, progress: 0);
|
||||
|
||||
if (!await _downloadAndMaybeFallback()) {
|
||||
await deleteFile(quarantined);
|
||||
return _FakeHiResOutcome.aborted;
|
||||
}
|
||||
final replacementPath = result['file_path'] as String?;
|
||||
if (await _shouldAbort(
|
||||
'during fake Hi-Res re-download',
|
||||
deleteFileOnAbort: result['success'] == true ? replacementPath : null,
|
||||
)) {
|
||||
await deleteFile(quarantined);
|
||||
return _FakeHiResOutcome.aborted;
|
||||
}
|
||||
|
||||
final replaced =
|
||||
result['success'] == true &&
|
||||
result['already_exists'] != true &&
|
||||
normalizeOptionalString(replacementPath) != null;
|
||||
if (!replaced) {
|
||||
_log.w(
|
||||
'[hires-check] ${HiResCheckService.fakeHiResFallbackQuality} '
|
||||
're-download of ${item.track.name} failed '
|
||||
'(${result['error'] ?? 'no file'}); keeping the flagged file',
|
||||
);
|
||||
keepQuarantine = true;
|
||||
return _FakeHiResOutcome.kept;
|
||||
}
|
||||
|
||||
await deleteFile(quarantined);
|
||||
// Mirrors _run: the SAF folder is resolved per result.
|
||||
if (effectiveSafMode && result['saf_relative_dir'] is String) {
|
||||
effectiveOutputDir = n._sanitizeSafRelativeDir(
|
||||
result['saf_relative_dir'] as String,
|
||||
);
|
||||
}
|
||||
_log.i(
|
||||
'[hires-check] replaced fake Hi-Res ${item.track.name} with '
|
||||
'$replacementPath',
|
||||
);
|
||||
return _FakeHiResOutcome.replaced;
|
||||
} catch (e) {
|
||||
_log.w('[hires-check] re-download of ${item.track.name} failed: $e');
|
||||
keepQuarantine = true;
|
||||
return _FakeHiResOutcome.kept;
|
||||
} finally {
|
||||
if (keepQuarantine) {
|
||||
saved.restore(this);
|
||||
try {
|
||||
await File(quarantined).rename(path);
|
||||
} catch (e) {
|
||||
_log.e('[hires-check] could not restore $path from $quarantined: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Only LOSSLESS is a meaningful replacement. A provider that does not
|
||||
/// list it would silently fall back to its default, which may be the very
|
||||
/// Hi-Res tier that produced the fake.
|
||||
bool _serviceOffersFakeHiResFallback() {
|
||||
final service = item.service.trim().toLowerCase();
|
||||
final extension = extensionState.extensions
|
||||
.where((e) => e.id.toLowerCase() == service)
|
||||
.firstOrNull;
|
||||
final options = extension?.qualityOptions ?? const <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,7 @@ class _DownloadRun {
|
||||
Map<String, dynamic>? probedFinalMetadata;
|
||||
bool externalLrcWritten = false;
|
||||
final Map<String, String> _directoryScopes = {};
|
||||
bool fakeHiResChecked = false;
|
||||
|
||||
Future<void> _run() async {
|
||||
final normalizedService = n._normalizeQueuedService(item.service);
|
||||
@@ -729,6 +730,7 @@ class _DownloadRun {
|
||||
decryptionDescriptor = DownloadDecryptionDescriptor.fromDownloadResult(
|
||||
result,
|
||||
);
|
||||
final requestTrack = trackToDownload;
|
||||
trackToDownload = buildTrackForMetadataEmbedding(
|
||||
trackToDownload,
|
||||
result,
|
||||
@@ -748,6 +750,19 @@ class _DownloadRun {
|
||||
return false;
|
||||
}
|
||||
|
||||
// At most once per run: the LOSSLESS replacement is not checked again.
|
||||
if (!fakeHiResChecked) {
|
||||
fakeHiResChecked = true;
|
||||
switch (await _replaceFakeHiResIfNeeded(requestTrack)) {
|
||||
case _FakeHiResOutcome.replaced:
|
||||
return _handleDownloadSuccess();
|
||||
case _FakeHiResOutcome.aborted:
|
||||
return false;
|
||||
case _FakeHiResOutcome.kept:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
final deferredSafPublish =
|
||||
effectiveSafMode &&
|
||||
result['saf_deferred_publish'] == true &&
|
||||
|
||||
@@ -792,6 +792,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setRedownloadFakeHiRes(bool enabled) {
|
||||
state = state.copyWith(redownloadFakeHiRes: enabled);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setAutoConvertFormat(String format) {
|
||||
state = state.copyWith(
|
||||
autoConvertFormat: normalizeAutoConvertFormat(format),
|
||||
|
||||
@@ -107,6 +107,15 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
showDivider: true,
|
||||
),
|
||||
],
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.verified_outlined,
|
||||
title: context.l10n.downloadRedownloadFakeHiRes,
|
||||
subtitle: context.l10n.downloadRedownloadFakeHiResSubtitle,
|
||||
value: settings.redownloadFakeHiRes,
|
||||
onChanged: (value) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setRedownloadFakeHiRes(value),
|
||||
),
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.auto_fix_high_outlined,
|
||||
title: context.l10n.downloadAutoConvert,
|
||||
|
||||
@@ -330,6 +330,12 @@ class SettingsSearchCatalog {
|
||||
title: l10n.downloadLossyFormat,
|
||||
keywords: const ['mp3', 'aac', 'opus'],
|
||||
),
|
||||
SettingsSearchEntry(
|
||||
icon: Icons.verified_outlined,
|
||||
title: l10n.downloadRedownloadFakeHiRes,
|
||||
subtitle: l10n.downloadRedownloadFakeHiResSubtitle,
|
||||
keywords: const ['hi-res', 'hires', 'fake', 'upsampled', '24-bit'],
|
||||
),
|
||||
SettingsSearchEntry(
|
||||
icon: Icons.auto_fix_high_outlined,
|
||||
title: l10n.downloadAutoConvert,
|
||||
|
||||
@@ -88,6 +88,16 @@ extension _TrackMetadataCards on _TrackMetadataScreenState {
|
||||
filePath: _filePath,
|
||||
codecHint: _storedAudioFormat,
|
||||
),
|
||||
if (HiResCheckCard.isCandidate(
|
||||
_filePath,
|
||||
_storedAudioFormat,
|
||||
)) ...[
|
||||
const SizedBox(height: 16),
|
||||
HiResCheckCard(
|
||||
filePath: _filePath,
|
||||
formatHint: _storedAudioFormat,
|
||||
),
|
||||
],
|
||||
],
|
||||
|
||||
if (!context.isMornye) ...[
|
||||
|
||||
@@ -64,6 +64,7 @@ import 'package:spotiflac_android/widgets/mornye_context_menu.dart';
|
||||
import 'package:spotiflac_android/widgets/mornye_metadata_row.dart';
|
||||
import 'package:spotiflac_android/widgets/player_artwork.dart';
|
||||
import 'package:spotiflac_android/widgets/audio_analysis_widget.dart';
|
||||
import 'package:spotiflac_android/widgets/hires_check_card.dart';
|
||||
import 'package:spotiflac_android/widgets/audio_quality_badges.dart';
|
||||
import 'package:spotiflac_android/widgets/batch_convert_sheet.dart';
|
||||
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
|
||||
/// Outcome of the Go fake Hi-Res check (go_backend/hires_check.go).
|
||||
class HiResCheckResult {
|
||||
final bool supported;
|
||||
final String verdict;
|
||||
final int declaredSampleRate;
|
||||
final double cutoffFrequencyHz;
|
||||
final int declaredBitDepth;
|
||||
final int effectiveBitDepth;
|
||||
final bool paddedBitDepth;
|
||||
final String reason;
|
||||
|
||||
/// For a fake: 'certain', 'likely' or 'suspect' (may be a genuine master
|
||||
/// low-pass filtered in mastering). Empty otherwise.
|
||||
final String confidence;
|
||||
|
||||
/// 'sample_hold', 'linear_interpolation', 'imaging', or empty.
|
||||
final String upsamplingArtifact;
|
||||
|
||||
/// True only when a LOSSLESS copy would lose nothing this file holds.
|
||||
final bool redownloadSafe;
|
||||
|
||||
/// Highest frequency whose level still moves with the music; 0 when not
|
||||
/// measured.
|
||||
final double musicCutoffHz;
|
||||
|
||||
/// True when the content past [musicCutoffHz] is steady noise (a DSD or
|
||||
/// analog tape transfer), so [cutoffFrequencyHz] is where noise ends.
|
||||
final bool ultrasonicNoiseOnly;
|
||||
|
||||
/// With [ultrasonicNoiseOnly]: the lowest standard rate that holds all
|
||||
/// the music. Otherwise the declared rate.
|
||||
final int usefulSampleRate;
|
||||
|
||||
const HiResCheckResult({
|
||||
required this.supported,
|
||||
this.verdict = '',
|
||||
this.declaredSampleRate = 0,
|
||||
this.cutoffFrequencyHz = 0,
|
||||
this.declaredBitDepth = 0,
|
||||
this.effectiveBitDepth = 0,
|
||||
this.paddedBitDepth = false,
|
||||
this.reason = '',
|
||||
this.confidence = '',
|
||||
this.upsamplingArtifact = '',
|
||||
this.redownloadSafe = false,
|
||||
this.musicCutoffHz = 0,
|
||||
this.ultrasonicNoiseOnly = false,
|
||||
this.usefulSampleRate = 0,
|
||||
});
|
||||
|
||||
factory HiResCheckResult.fromJson(Map<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 Go thresholds: a rate claim above 48 kHz whose content
|
||||
/// stops below 28 kHz. Recomputed here so the reason can be localized.
|
||||
bool get upsampled =>
|
||||
isFake && declaredSampleRate > 48000 && cutoffFrequencyHz < 28000;
|
||||
}
|
||||
|
||||
class HiResCheckService {
|
||||
/// The requested qualities that actually claim Hi-Res. A LOSSLESS request
|
||||
/// answered with CD-range audio is not a fake: it is what was asked for.
|
||||
static const hiResQualities = {'HI_RES', 'HI_RES_LOSSLESS'};
|
||||
|
||||
/// What a flagged download is replaced with. An upsampled 24/96 file holds
|
||||
/// no more information than the CD master it was made from, so this is the
|
||||
/// same audio, honestly labelled and smaller.
|
||||
static const fakeHiResFallbackQuality = 'LOSSLESS';
|
||||
|
||||
/// Suffix of the flagged file while its replacement downloads. It is only
|
||||
/// deleted once the replacement exists: a heuristic is not a good enough
|
||||
/// reason to leave the user with no copy of the track at all.
|
||||
static const quarantineSuffix = '.fake-hires.bak';
|
||||
|
||||
static bool isHiResQuality(String quality) =>
|
||||
hiResQualities.contains(quality.trim().toUpperCase());
|
||||
|
||||
/// Runs the check; throws on failure. `supported == false` for formats
|
||||
/// the Go checker cannot decode (anything but FLAC and PCM WAV).
|
||||
static Future<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);
|
||||
}
|
||||
}
|
||||
@@ -1156,6 +1156,18 @@ class PlatformBridge {
|
||||
});
|
||||
}
|
||||
|
||||
/// Runs the Go fake Hi-Res check (spectral cutoff + padded bit depth) on
|
||||
/// a FLAC or WAV file. Returns `{"supported": false}` for other formats.
|
||||
static Future<Map<String, dynamic>> checkHiResAuthenticity(
|
||||
String filePath, {
|
||||
Map<String, dynamic>? options,
|
||||
}) {
|
||||
return _invokeMap('checkHiResAuthenticity', {
|
||||
'file_path': filePath,
|
||||
'options_json': options == null ? '' : jsonEncode(options),
|
||||
});
|
||||
}
|
||||
|
||||
/// Reads the tags and quality fields used for automatic Library display.
|
||||
///
|
||||
/// Android's readAudioMetadata implementation can inspect most SAF files
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/services/hires_check_service.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
|
||||
/// On-demand fake Hi-Res check for one FLAC/WAV file: spectral cutoff for
|
||||
/// upsampling, and unused low bits for a 16-bit master padded to 24.
|
||||
class HiResCheckCard extends StatefulWidget {
|
||||
final String filePath;
|
||||
final String? formatHint;
|
||||
|
||||
const HiResCheckCard({super.key, required this.filePath, this.formatHint});
|
||||
|
||||
/// Only FLAC and PCM WAV are decoded by the Go checker.
|
||||
static bool isCandidate(String filePath, String? formatHint) {
|
||||
final format = formatHint?.toLowerCase().trim() ?? '';
|
||||
if (format == 'flac' || format == 'wav') return true;
|
||||
final lower = filePath.toLowerCase();
|
||||
return lower.endsWith('.flac') || lower.endsWith('.wav');
|
||||
}
|
||||
|
||||
@override
|
||||
State<HiResCheckCard> createState() => _HiResCheckCardState();
|
||||
}
|
||||
|
||||
class _HiResCheckCardState extends State<HiResCheckCard> {
|
||||
HiResCheckResult? _result;
|
||||
bool _checking = false;
|
||||
String? _error;
|
||||
int _requestId = 0;
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant HiResCheckCard oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.filePath != widget.filePath) {
|
||||
_requestId++;
|
||||
_result = null;
|
||||
_error = null;
|
||||
_checking = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _check() async {
|
||||
final requestId = ++_requestId;
|
||||
setState(() {
|
||||
_checking = true;
|
||||
_error = null;
|
||||
});
|
||||
String? error;
|
||||
HiResCheckResult? result;
|
||||
try {
|
||||
result = await HiResCheckService.check(widget.filePath);
|
||||
} on StateError catch (e) {
|
||||
error = e.message;
|
||||
} catch (e) {
|
||||
error = e.toString();
|
||||
}
|
||||
if (!mounted || requestId != _requestId) return;
|
||||
setState(() {
|
||||
_checking = false;
|
||||
_result = result;
|
||||
_error = error;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!HiResCheckCard.isCandidate(widget.filePath, widget.formatHint)) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final l10n = context.l10n;
|
||||
final result = _result;
|
||||
|
||||
return Card(
|
||||
elevation: 0,
|
||||
color: settingsGroupColor(context),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
side: BorderSide(color: cs.outlineVariant.withValues(alpha: 0.5)),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.verified_outlined, color: cs.primary, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.hiResCheckTitle,
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_checking)
|
||||
const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2.5),
|
||||
)
|
||||
else if (result != null || _error != null)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh, size: 20),
|
||||
tooltip: l10n.audioAnalysisRescan,
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 48,
|
||||
minHeight: 48,
|
||||
),
|
||||
color: cs.onSurfaceVariant,
|
||||
onPressed: _check,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (_checking)
|
||||
_secondaryText(l10n.hiResCheckChecking, cs)
|
||||
else if (_error != null)
|
||||
Text(
|
||||
l10n.hiResCheckFailed(_error!),
|
||||
style: TextStyle(color: cs.error, fontSize: 13),
|
||||
)
|
||||
else if (result == null) ...[
|
||||
_secondaryText(l10n.hiResCheckDescription, cs),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: _check,
|
||||
icon: const Icon(Icons.search, size: 18),
|
||||
label: Text(l10n.hiResCheckRun),
|
||||
),
|
||||
] else if (!result.supported)
|
||||
_secondaryText(l10n.hiResCheckUnsupported, cs)
|
||||
else
|
||||
..._buildResult(context, result, cs),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _secondaryText(String text, ColorScheme cs) {
|
||||
return Text(
|
||||
text,
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildResult(
|
||||
BuildContext context,
|
||||
HiResCheckResult result,
|
||||
ColorScheme cs,
|
||||
) {
|
||||
final l10n = context.l10n;
|
||||
final (IconData icon, Color color, String label) = switch (result.verdict) {
|
||||
'fake_hires' when result.isCertain => (
|
||||
Icons.error_outline,
|
||||
cs.error,
|
||||
l10n.hiResCheckVerdictFakeCertain,
|
||||
),
|
||||
'fake_hires' when result.isSuspect => (
|
||||
Icons.help_outline,
|
||||
cs.tertiary,
|
||||
l10n.hiResCheckVerdictFakeSuspect,
|
||||
),
|
||||
'fake_hires' => (
|
||||
Icons.warning_amber_rounded,
|
||||
cs.error,
|
||||
l10n.hiResCheckVerdictFake,
|
||||
),
|
||||
'genuine_hires' => (
|
||||
Icons.check_circle_outline,
|
||||
cs.primary,
|
||||
l10n.hiResCheckVerdictGenuine,
|
||||
),
|
||||
'standard_definition' => (
|
||||
Icons.info_outline,
|
||||
cs.onSurfaceVariant,
|
||||
l10n.hiResCheckVerdictStandard,
|
||||
),
|
||||
_ => (
|
||||
Icons.help_outline,
|
||||
cs.onSurfaceVariant,
|
||||
l10n.hiResCheckVerdictInconclusive,
|
||||
),
|
||||
};
|
||||
|
||||
final effectiveBits = result.effectiveBitDepth > 0
|
||||
? result.effectiveBitDepth
|
||||
: null;
|
||||
final reasons = <String>[
|
||||
if (result.upsamplingArtifact == 'sample_hold')
|
||||
l10n.hiResCheckReasonSampleHold,
|
||||
if (result.upsamplingArtifact == 'linear_interpolation')
|
||||
l10n.hiResCheckReasonInterpolation,
|
||||
if (result.upsamplingArtifact == 'imaging') l10n.hiResCheckReasonImaging,
|
||||
if (result.upsampled)
|
||||
l10n.hiResCheckReasonRate(
|
||||
_formatKHz(result.declaredSampleRate.toDouble()),
|
||||
_formatKHz(result.cutoffFrequencyHz),
|
||||
),
|
||||
if (result.isFake && result.paddedBitDepth)
|
||||
l10n.hiResCheckReasonDepth(
|
||||
result.declaredBitDepth,
|
||||
result.effectiveBitDepth,
|
||||
),
|
||||
];
|
||||
|
||||
return [
|
||||
Row(
|
||||
children: [
|
||||
Icon(icon, color: color, size: 18),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
for (final reason in reasons) ...[
|
||||
const SizedBox(height: 4),
|
||||
_secondaryText('• $reason', cs),
|
||||
],
|
||||
if (result.ultrasonicNoiseOnly) ...[
|
||||
const SizedBox(height: 4),
|
||||
_secondaryText(
|
||||
'• ${l10n.hiResCheckUltrasonicNoise(_formatKHz(result.musicCutoffHz), _formatKHz(result.usefulSampleRate.toDouble()))}',
|
||||
cs,
|
||||
),
|
||||
],
|
||||
if (result.verdict != 'inconclusive') ...[
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 16,
|
||||
runSpacing: 4,
|
||||
children: [
|
||||
// Past the music only steady noise remains, so where it ends
|
||||
// says nothing about the music: show the music's own edge.
|
||||
if (result.ultrasonicNoiseOnly)
|
||||
_detail(
|
||||
l10n.hiResCheckMusicContent,
|
||||
'~${_formatKHz(result.musicCutoffHz)}',
|
||||
cs,
|
||||
)
|
||||
else
|
||||
_detail(
|
||||
l10n.hiResCheckCutoff,
|
||||
'~${_formatKHz(result.cutoffFrequencyHz)}',
|
||||
cs,
|
||||
),
|
||||
if (result.declaredBitDepth > 0)
|
||||
_detail(
|
||||
l10n.hiResCheckBitsInUse,
|
||||
'${effectiveBits ?? '?'} / ${result.declaredBitDepth}-bit',
|
||||
cs,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
if (result.isSuspect) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
l10n.hiResCheckDisclaimer,
|
||||
style: TextStyle(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontSize: 11,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
Widget _detail(String label, String value, ColorScheme cs) {
|
||||
return Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: '$label: ',
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12),
|
||||
),
|
||||
TextSpan(
|
||||
text: value,
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatKHz(double hz) => '${(hz / 1000).toStringAsFixed(1)} kHz';
|
||||
}
|
||||
Reference in New Issue
Block a user