mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-30 13:19:44 +02:00
Keep automatic Hi-Res replacement off by default. Require exact full-duration PCM equivalence across every channel before and after processing. Retain the original through publication and history persistence, restore it on failure or cancellation, and bound WAV format parsing. Validated with 30 Rust tests, 46 Dart tests, clean analyzer/Clippy checks, Android ARM64 and iOS builds, and iOS simulator startup.
163 lines
6.0 KiB
Dart
163 lines
6.0 KiB
Dart
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.
|
|
}
|
|
}
|
|
}
|
|
}
|