diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 25507507..88b9ac12 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -4936,6 +4936,61 @@ "@hiResCheckTitle": { "description": "Title of the fake Hi-Res check card" }, + "hiResCheckSummary": "Inspect the spectrum and active bit depth for signs of upsampling or padding.", + "@hiResCheckSummary": { + "description": "Introductory text for the sampled Hi-Res analysis" + }, + "hiResCheckLimitedBandwidth": "Limited bandwidth", + "@hiResCheckLimitedBandwidth": { + "description": "Neutral finding: a limited spectrum alone does not prove upsampling" + }, + "hiResCheckNoEvidence": "No upsampling or padding detected", + "@hiResCheckNoEvidence": { + "description": "No evidence was detected; this does not certify the master" + }, + "hiResCheckEstimatedCutoff": "Estimated cutoff", + "@hiResCheckEstimatedCutoff": { + "description": "Estimated upper spectral edge at the analysis threshold" + }, + "hiResCheckActiveBits": "Active / stored bits", + "@hiResCheckActiveBits": { + "description": "Active integer bit depth versus the container depth in the sampled segment" + }, + "hiResCheckBandwidthExplanation": "A lower cutoff can come from mastering. It does not, by itself, prove upsampling.", + "@hiResCheckBandwidthExplanation": { + "description": "Explains why the limited-bandwidth finding is neutral" + }, + "hiResCheckSampledExplanation": "Based on {seconds} seconds of audio. This analysis cannot certify the original master.", + "@hiResCheckSampledExplanation": { + "description": "Limits of the sampled Hi-Res analysis; seconds is a formatted duration", + "placeholders": { + "seconds": { + "type": "String" + } + } + }, + "hiResCheckImagingEvidence": "The spectrum contains mirrored bands consistent with upsampling.", + "@hiResCheckImagingEvidence": { + "description": "Spectral imaging is suggestive evidence rather than proof" + }, + "hiResCheckRateEvidence": "A sharp roll-off near {cutoff} and the measured noise floor suggest a lower-rate source.", + "@hiResCheckRateEvidence": { + "description": "Heuristic evidence from both a brickwall and a 16-bit-like noise floor", + "placeholders": { + "cutoff": { + "type": "String" + } + } + }, + "hiResCheckSteadyEnergy": "Energy above {cutoff} is mostly steady in the analyzed segment and may be noise.", + "@hiResCheckSteadyEnergy": { + "description": "An observation about ultrasonic energy, not proof that removing it is safe", + "placeholders": { + "cutoff": { + "type": "String" + } + } + }, "hiResCheckDescription": "Detect upsampled or bit-padded fake Hi-Res files", "@hiResCheckDescription": { "description": "Explains what the Hi-Res authenticity check does" diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index 9c892887..75f8c16f 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -6324,5 +6324,18 @@ "profileChangePhoto": "Pilih foto", "profileRemovePhoto": "Hapus foto", "profilePhotoError": "Pilih gambar yang valid dengan ukuran kurang dari 20 MB.", - "profileSaveError": "Profil belum berhasil disimpan. Silakan coba lagi." + "profileSaveError": "Profil belum berhasil disimpan. Silakan coba lagi.", + "hiResCheckTitle": "Keaslian Hi-Res", + "hiResCheckSummary": "Periksa spektrum dan kedalaman bit aktif untuk menemukan tanda upsampling atau padding.", + "hiResCheckRun": "Periksa", + "hiResCheckChecking": "Memeriksa audio Hi-Res...", + "hiResCheckLimitedBandwidth": "Rentang frekuensi terbatas", + "hiResCheckNoEvidence": "Tidak terdeteksi upsampling atau padding", + "hiResCheckEstimatedCutoff": "Perkiraan cutoff", + "hiResCheckActiveBits": "Bit aktif / tersimpan", + "hiResCheckBandwidthExplanation": "Cutoff yang lebih rendah bisa berasal dari proses mastering. Hal ini saja tidak membuktikan adanya upsampling.", + "hiResCheckSampledExplanation": "Berdasarkan {seconds} detik audio. Analisis ini tidak dapat memastikan keaslian master sumber.", + "hiResCheckImagingEvidence": "Spektrum memiliki pola pita tercermin yang konsisten dengan upsampling.", + "hiResCheckRateEvidence": "Penurunan tajam di sekitar {cutoff} dan noise floor yang terukur mengindikasikan sumber dengan sample rate lebih rendah.", + "hiResCheckSteadyEnergy": "Energi di atas {cutoff} relatif konstan pada bagian yang dianalisis dan mungkin berupa noise." } diff --git a/lib/services/hires_check_service.dart b/lib/services/hires_check_service.dart index 6a39b416..60965d8f 100644 --- a/lib/services/hires_check_service.dart +++ b/lib/services/hires_check_service.dart @@ -18,6 +18,8 @@ class HiResCheckResult { final String verdict; final int declaredSampleRate; final double cutoffFrequencyHz; + final double analyzedDurationSeconds; + final double brickwallHz; final int declaredBitDepth; final int effectiveBitDepth; final bool paddedBitDepth; @@ -50,6 +52,8 @@ class HiResCheckResult { this.verdict = '', this.declaredSampleRate = 0, this.cutoffFrequencyHz = 0, + this.analyzedDurationSeconds = 0, + this.brickwallHz = 0, this.declaredBitDepth = 0, this.effectiveBitDepth = 0, this.paddedBitDepth = false, @@ -68,6 +72,9 @@ class HiResCheckResult { verdict: json['verdict'] as String? ?? '', declaredSampleRate: (json['declared_sample_rate'] as num?)?.toInt() ?? 0, cutoffFrequencyHz: (json['cutoff_frequency_hz'] as num?)?.toDouble() ?? 0, + analyzedDurationSeconds: + (json['analyzed_duration_s'] as num?)?.toDouble() ?? 0, + brickwallHz: (json['brickwall_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, @@ -86,10 +93,9 @@ class HiResCheckResult { 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; + /// Older backends called a limited spectrum "suspect". Neither that label + /// nor the cutoff alone establishes that the file was upsampled. + bool get hasLimitedBandwidth => verdict == 'band_limited' || isSuspect; } class HiResCheckService { diff --git a/lib/widgets/hires_check_card.dart b/lib/widgets/hires_check_card.dart index 20d27c67..43654b15 100644 --- a/lib/widgets/hires_check_card.dart +++ b/lib/widgets/hires_check_card.dart @@ -1,17 +1,21 @@ +import 'package:flutter/cupertino.dart'; 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/theme/mornye_icons.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/app_action_button.dart'; +import 'package:spotiflac_android/widgets/app_content_card.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. +/// On-demand sampled spectrum and bit-depth analysis for one FLAC/WAV file. 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. + /// Only FLAC and PCM WAV are decoded by the Rust checker. static bool isCandidate(String filePath, String? formatHint) { final format = formatHint?.toLowerCase().trim() ?? ''; if (format == 'flac' || format == 'wav') return true; @@ -71,8 +75,9 @@ class _HiResCheckCardState extends State { final cs = Theme.of(context).colorScheme; final l10n = context.l10n; final result = _result; + final mornye = context.isMornye; - return Card( + return AppContentCard( elevation: 0, color: settingsGroupColor(context), shape: RoundedRectangleBorder( @@ -80,29 +85,47 @@ class _HiResCheckCardState extends State { side: BorderSide(color: cs.outlineVariant.withValues(alpha: 0.5)), ), child: Padding( - padding: const EdgeInsets.all(16), + padding: EdgeInsets.all(mornye ? 20 : 16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ - Icon(Icons.verified_outlined, color: cs.primary, size: 20), - const SizedBox(width: 8), + if (!mornye) ...[ + Icon(Icons.graphic_eq, color: cs.onSurfaceVariant, size: 22), + const SizedBox(width: 10), + ], Expanded( child: Text( l10n.hiResCheckTitle, style: TextStyle( color: cs.onSurface, fontWeight: FontWeight.w600, - fontSize: 14, + fontSize: 17, ), ), ), if (_checking) - const SizedBox( + SizedBox( width: 20, height: 20, - child: CircularProgressIndicator(strokeWidth: 2.5), + child: mornye + ? const CupertinoActivityIndicator() + : const CircularProgressIndicator(strokeWidth: 2.5), + ) + else if (mornye && (result != null || _error != null)) + Tooltip( + message: l10n.audioAnalysisRescan, + child: CupertinoButton( + padding: const EdgeInsets.all(12), + onPressed: _check, + child: Icon( + CupertinoIcons.refresh, + color: cs.onSurfaceVariant, + size: 22, + semanticLabel: l10n.audioAnalysisRescan, + ), + ), ) else if (result != null || _error != null) IconButton( @@ -125,15 +148,26 @@ class _HiResCheckCardState extends State { else if (_error != null) Text( l10n.hiResCheckFailed(_error!), - style: TextStyle(color: cs.error, fontSize: 13), + style: TextStyle(color: cs.error, fontSize: 15), ) 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), + _secondaryText(l10n.hiResCheckSummary, cs), + const SizedBox(height: 20), + SizedBox( + width: double.infinity, + child: mornye + ? AppActionButton( + outlined: true, + tonal: true, + onPressed: _check, + icon: const Icon(Icons.search), + label: Text(l10n.hiResCheckRun), + ) + : FilledButton.tonalIcon( + onPressed: _check, + icon: const Icon(Icons.search, size: 20), + label: Text(l10n.hiResCheckRun), + ), ), ] else if (!result.supported) _secondaryText(l10n.hiResCheckUnsupported, cs) @@ -148,7 +182,7 @@ class _HiResCheckCardState extends State { Widget _secondaryText(String text, ColorScheme cs) { return Text( text, - style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 15, height: 1.35), ); } @@ -164,10 +198,10 @@ class _HiResCheckCardState extends State { cs.error, l10n.hiResCheckVerdictFakeCertain, ), - 'fake_hires' when result.isSuspect => ( - Icons.help_outline, - cs.tertiary, - l10n.hiResCheckVerdictFakeSuspect, + _ when result.hasLimitedBandwidth => ( + Icons.info_outline, + cs.onSurface, + l10n.hiResCheckLimitedBandwidth, ), 'fake_hires' => ( Icons.warning_amber_rounded, @@ -176,8 +210,8 @@ class _HiResCheckCardState extends State { ), 'genuine_hires' => ( Icons.check_circle_outline, - cs.primary, - l10n.hiResCheckVerdictGenuine, + cs.onSurface, + l10n.hiResCheckNoEvidence, ), 'standard_definition' => ( Icons.info_outline, @@ -199,12 +233,13 @@ class _HiResCheckCardState extends State { 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.upsamplingArtifact == 'imaging') + l10n.hiResCheckImagingEvidence, + if (result.isFake && + result.confidence == 'likely' && + result.upsamplingArtifact.isEmpty && + result.brickwallHz > 0) + l10n.hiResCheckRateEvidence(_formatKHz(result.brickwallHz)), if (result.isFake && result.paddedBitDepth) l10n.hiResCheckReasonDepth( result.declaredBitDepth, @@ -215,88 +250,95 @@ class _HiResCheckCardState extends State { return [ Row( children: [ - Icon(icon, color: color, size: 18), - const SizedBox(width: 6), + Icon(context.adaptiveIcon(icon), color: color, size: 22), + const SizedBox(width: 10), Expanded( child: Text( label, style: TextStyle( color: color, fontWeight: FontWeight.w600, - fontSize: 13, + fontSize: 16, ), ), ), ], ), for (final reason in reasons) ...[ - const SizedBox(height: 4), + const SizedBox(height: 10), _secondaryText('• $reason', cs), ], if (result.ultrasonicNoiseOnly) ...[ - const SizedBox(height: 4), + const SizedBox(height: 10), _secondaryText( - '• ${l10n.hiResCheckUltrasonicNoise(_formatKHz(result.musicCutoffHz), _formatKHz(result.usefulSampleRate.toDouble()))}', + l10n.hiResCheckSteadyEnergy(_formatKHz(result.musicCutoffHz)), cs, ), ], if (result.verdict != 'inconclusive') ...[ + const SizedBox(height: 16), + const Divider(height: 1), 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, - ), - ], + _detail( + l10n.audioAnalysisSampleRate, + _formatKHz(result.declaredSampleRate.toDouble()), + cs, ), - ], - if (result.isSuspect) ...[ - const SizedBox(height: 8), - Text( - l10n.hiResCheckDisclaimer, - style: TextStyle( - color: cs.onSurfaceVariant, - fontSize: 11, - fontStyle: FontStyle.italic, + _detail( + l10n.audioAnalysisNyquist, + _formatKHz(result.declaredSampleRate / 2), + cs, + ), + _detail( + l10n.hiResCheckEstimatedCutoff, + '~${_formatKHz(result.cutoffFrequencyHz)}', + cs, + ), + if (result.declaredBitDepth > 0) + _detail( + l10n.hiResCheckActiveBits, + '${effectiveBits ?? '?'} / ${result.declaredBitDepth}', + cs, ), + ], + if (result.hasLimitedBandwidth) ...[ + const SizedBox(height: 16), + _secondaryText(l10n.hiResCheckBandwidthExplanation, cs), + ], + if (result.analyzedDurationSeconds > 0) ...[ + const SizedBox(height: 12), + _secondaryText( + l10n.hiResCheckSampledExplanation( + result.analyzedDurationSeconds.toStringAsFixed(1), + ), + cs, ), ], ]; } Widget _detail(String label, String value, ColorScheme cs) { - return Text.rich( - TextSpan( + return Padding( + padding: const EdgeInsets.symmetric(vertical: 7), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - TextSpan( - text: '$label: ', - style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12), + Expanded( + child: Text( + label, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 15), + ), ), - TextSpan( - text: value, - style: TextStyle( - color: cs.onSurface, - fontSize: 12, - fontWeight: FontWeight.w600, + const SizedBox(width: 16), + Expanded( + child: Text( + value, + textAlign: TextAlign.end, + style: TextStyle( + color: cs.onSurface, + fontSize: 15, + fontWeight: FontWeight.w600, + ), ), ), ], diff --git a/test/hires_check_card_test.dart b/test/hires_check_card_test.dart new file mode 100644 index 00000000..cb6c228b --- /dev/null +++ b/test/hires_check_card_test.dart @@ -0,0 +1,202 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/theme/app_theme.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/hires_check_card.dart'; + +const _limitedResult = { + 'supported': true, + 'verdict': 'band_limited', + 'declared_sample_rate': 96000, + 'cutoff_frequency_hz': 27164.0625, + 'declared_bit_depth': 24, + 'effective_bit_depth': 24, + 'analyzed_duration_s': 30, +}; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const channel = MethodChannel('com.zarz.spotiflac/backend'); + final capture = GlobalKey(); + var calls = 0; + + setUpAll(() async { + for (final (family, asset) in [ + ('MaterialIcons', 'fonts/MaterialIcons-Regular.otf'), + ('Google Sans Flex', 'assets/fonts/GoogleSansFlex.ttf'), + ('Inter', 'assets/fonts/InterVariable.ttf'), + ( + 'packages/cupertino_icons/CupertinoIcons', + 'packages/cupertino_icons/assets/CupertinoIcons.ttf', + ), + ]) { + await (FontLoader(family)..addFont(rootBundle.load(asset))).load(); + } + }); + + setUp(() { + calls = 0; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + expect(call.method, 'checkHiResAuthenticity'); + calls++; + return _limitedResult; + }); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + Future openCard( + WidgetTester tester, { + bool mornye = true, + Brightness brightness = Brightness.light, + double textScale = 1, + String path = '/music/track.flac', + }) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.pumpWidget( + MaterialApp( + theme: mornye + ? MornyeTheme.build(brightness) + : brightness == Brightness.light + ? AppTheme.light() + : AppTheme.dark(), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + locale: const Locale('en'), + builder: (context, child) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: TextScaler.linear(textScale)), + child: RepaintBoundary(key: capture, child: child), + ), + home: Scaffold( + body: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: HiResCheckCard(filePath: path), + ), + ), + ), + ); + await tester.pump(); + } + + Future captureCard(WidgetTester tester, String name) async { + if (!const bool.fromEnvironment('CAPTURE_HIRES_CARD')) return; + await tester.runAsync(() async { + final boundary = + capture.currentContext!.findRenderObject()! as RenderRepaintBoundary; + final image = await boundary.toImage(pixelRatio: 2); + final data = await image.toByteData(format: ui.ImageByteFormat.png); + await File( + '/tmp/spotiflac-hires-$name.png', + ).writeAsBytes(data!.buffer.asUint8List()); + image.dispose(); + }); + } + + for (final mornye in [true, false]) { + for (final brightness in Brightness.values) { + final name = '${mornye ? 'mornye' : 'material'}-${brightness.name}'; + testWidgets('centered action and neutral bandwidth result ($name)', ( + tester, + ) async { + await openCard(tester, mornye: mornye, brightness: brightness); + await tester.pumpAndSettle(); + final button = find.ancestor( + of: find.text('Check'), + matching: mornye + ? find.byType(CupertinoButton) + : find.bySubtype(), + ); + expect(button, findsOneWidget); + expect(tester.getSize(button).width, greaterThan(300)); + expect(tester.getCenter(button).dx, closeTo(195, 1)); + expect(find.byType(Card), mornye ? findsNothing : findsOneWidget); + await captureCard(tester, '$name-idle'); + + await tester.tap(find.text('Check')); + await tester.pumpAndSettle(); + expect(calls, 1); + expect(find.text('Limited bandwidth'), findsOneWidget); + expect(find.text('Fake Hi-Res'), findsNothing); + expect(find.text('96.0 kHz'), findsOneWidget); + expect(find.text('48.0 kHz'), findsOneWidget); + expect(find.text('~27.2 kHz'), findsOneWidget); + expect(find.text('24 / 24'), findsOneWidget); + final rowValue = tester.getRect(find.text('~27.2 kHz')); + final rowLabel = tester.getRect(find.text('Estimated cutoff')); + expect(rowValue.top, closeTo(rowLabel.top, 1)); + expect(rowValue.left, greaterThan(rowLabel.right)); + expect(rowValue.right, closeTo(354, 1)); + expect(tester.takeException(), isNull); + await captureCard(tester, '$name-result'); + await tester.tap(find.byTooltip('Re-analyze')); + await tester.pumpAndSettle(); + expect(calls, 2); + }); + } + } + + for (final mornye in [true, false]) { + testWidgets('large text stays readable ($mornye)', (tester) async { + await openCard(tester, mornye: mornye, textScale: 2); + await tester.tap(find.text('Check')); + await tester.pumpAndSettle(); + await tester.scrollUntilVisible( + find.textContaining('Based on 30.0 seconds'), + 180, + ); + expect(tester.takeException(), isNull); + }); + } + + testWidgets( + 'a result from the previous track cannot overwrite the new track', + (tester) async { + final pending = Completer>(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (_) => pending.future); + await openCard(tester); + await tester.tap(find.text('Check')); + await tester.pump(); + await openCard(tester, path: '/music/another.flac'); + pending.complete(_limitedResult); + await tester.pumpAndSettle(); + expect(find.text('Check'), findsOneWidget); + expect(find.text('Limited bandwidth'), findsNothing); + }, + ); + + testWidgets('legacy suspect results are not presented as proven fakes', ( + tester, + ) async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + channel, + (_) async => { + ..._limitedResult, + 'verdict': 'fake_hires', + 'confidence': 'suspect', + }, + ); + await openCard(tester); + await tester.tap(find.text('Check')); + await tester.pumpAndSettle(); + expect(find.text('Limited bandwidth'), findsOneWidget); + expect(find.textContaining('Declares'), findsNothing); + }); +}