From d85c8f6a171ab776b89515cf8e3bb3842b6a178c Mon Sep 17 00:00:00 2001 From: zarzet Date: Thu, 30 Jul 2026 15:14:36 +0700 Subject: [PATCH] fix(analysis): detect full-band spectral cutoff --- lib/l10n/app_localizations.dart | 6 +++ lib/l10n/app_localizations_de.dart | 3 ++ lib/l10n/app_localizations_en.dart | 3 ++ lib/l10n/app_localizations_es.dart | 3 ++ lib/l10n/app_localizations_fr.dart | 3 ++ lib/l10n/app_localizations_id.dart | 3 ++ lib/l10n/app_localizations_ja.dart | 3 ++ lib/l10n/app_localizations_ko.dart | 3 ++ lib/l10n/app_localizations_pt.dart | 3 ++ lib/l10n/app_localizations_ru.dart | 3 ++ lib/l10n/app_localizations_tr.dart | 3 ++ lib/l10n/app_localizations_uk.dart | 3 ++ lib/l10n/arb/app_en.arb | 4 ++ lib/l10n/arb/app_id.arb | 1 + lib/widgets/audio_analysis_info_card.dart | 20 +++++----- lib/widgets/audio_analysis_models.dart | 2 +- lib/widgets/audio_analysis_widget.dart | 22 +++++++++-- test/audio_analysis_info_card_test.dart | 26 +++++++++++++ test/audio_analysis_spectrogram_test.dart | 45 +++++++++++++++++++++++ 19 files changed, 144 insertions(+), 15 deletions(-) create mode 100644 test/audio_analysis_info_card_test.dart diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index da6a27ca..c1805979 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -5936,6 +5936,12 @@ abstract class AppLocalizations { /// **'Spectral Cutoff'** String get audioAnalysisSpectralCutoff; + /// Displayed when no reliable broadband spectral cutoff can be detected + /// + /// In en, this message translates to: + /// **'Not detected'** + String get audioAnalysisCutoffNotDetected; + /// Per-channel audio analysis section label /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 60e552ac..547db1a7 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -3597,6 +3597,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get audioAnalysisSpectralCutoff => 'Spektralschnitt'; + @override + String get audioAnalysisCutoffNotDetected => 'Not detected'; + @override String get audioAnalysisChannelStats => 'Pro Kanal Statistik'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 078f3ae9..7d5d3d91 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -3555,6 +3555,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get audioAnalysisSpectralCutoff => 'Spectral Cutoff'; + @override + String get audioAnalysisCutoffNotDetected => 'Not detected'; + @override String get audioAnalysisChannelStats => 'Per-channel Stats'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index db39b8b8..d76b7bf5 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -3555,6 +3555,9 @@ class AppLocalizationsEs extends AppLocalizations { @override String get audioAnalysisSpectralCutoff => 'Spectral Cutoff'; + @override + String get audioAnalysisCutoffNotDetected => 'Not detected'; + @override String get audioAnalysisChannelStats => 'Per-channel Stats'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 85d2c004..fb9a2d56 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -3648,6 +3648,9 @@ class AppLocalizationsFr extends AppLocalizations { @override String get audioAnalysisSpectralCutoff => 'Limite spectrale'; + @override + String get audioAnalysisCutoffNotDetected => 'Not detected'; + @override String get audioAnalysisChannelStats => 'Statistiques par chaîne'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 23414ab4..b7a254b5 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -3552,6 +3552,9 @@ class AppLocalizationsId extends AppLocalizations { @override String get audioAnalysisSpectralCutoff => 'Spectral Cutoff'; + @override + String get audioAnalysisCutoffNotDetected => 'Tidak terdeteksi'; + @override String get audioAnalysisChannelStats => 'Per-channel Stats'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index df83a1a3..a1dafd65 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -3544,6 +3544,9 @@ class AppLocalizationsJa extends AppLocalizations { @override String get audioAnalysisSpectralCutoff => 'Spectral Cutoff'; + @override + String get audioAnalysisCutoffNotDetected => 'Not detected'; + @override String get audioAnalysisChannelStats => 'Per-channel Stats'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 6584e9fd..5ec4d9ce 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -3453,6 +3453,9 @@ class AppLocalizationsKo extends AppLocalizations { @override String get audioAnalysisSpectralCutoff => '주파수 컷오프'; + @override + String get audioAnalysisCutoffNotDetected => 'Not detected'; + @override String get audioAnalysisChannelStats => '채널별 통계'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index c328fb90..b6015954 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -3555,6 +3555,9 @@ class AppLocalizationsPt extends AppLocalizations { @override String get audioAnalysisSpectralCutoff => 'Spectral Cutoff'; + @override + String get audioAnalysisCutoffNotDetected => 'Not detected'; + @override String get audioAnalysisChannelStats => 'Per-channel Stats'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index b9e691ee..28821503 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -3584,6 +3584,9 @@ class AppLocalizationsRu extends AppLocalizations { @override String get audioAnalysisSpectralCutoff => 'Spectral Cutoff'; + @override + String get audioAnalysisCutoffNotDetected => 'Not detected'; + @override String get audioAnalysisChannelStats => 'Per-channel Stats'; diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index 6b74d3d6..38310394 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -3585,6 +3585,9 @@ class AppLocalizationsTr extends AppLocalizations { @override String get audioAnalysisSpectralCutoff => 'Spectral Cutoff'; + @override + String get audioAnalysisCutoffNotDetected => 'Not detected'; + @override String get audioAnalysisChannelStats => 'Per-channel Stats'; diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index 09aa482e..529c110e 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -3600,6 +3600,9 @@ class AppLocalizationsUk extends AppLocalizations { @override String get audioAnalysisSpectralCutoff => 'Spectral Cutoff'; + @override + String get audioAnalysisCutoffNotDetected => 'Not detected'; + @override String get audioAnalysisChannelStats => 'Per-channel Stats'; diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index b9fbc5db..31b736c3 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -4577,6 +4577,10 @@ "@audioAnalysisSpectralCutoff": { "description": "Estimated spectral cutoff metric label" }, + "audioAnalysisCutoffNotDetected": "Not detected", + "@audioAnalysisCutoffNotDetected": { + "description": "Displayed when no reliable broadband spectral cutoff can be detected" + }, "audioAnalysisChannelStats": "Per-channel Stats", "@audioAnalysisChannelStats": { "description": "Per-channel audio analysis section label" diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index 6a6974f5..3d2e4ed1 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -3432,6 +3432,7 @@ "description": "Cache item title for local library cover art images" }, "audioAnalysisSpectralCutoff": "Spectral Cutoff", + "audioAnalysisCutoffNotDetected": "Tidak terdeteksi", "dialogDelete": "Hapus", "@dialogDelete": { "description": "Dialog button - delete item" diff --git a/lib/widgets/audio_analysis_info_card.dart b/lib/widgets/audio_analysis_info_card.dart index 805e439b..1c87bb83 100644 --- a/lib/widgets/audio_analysis_info_card.dart +++ b/lib/widgets/audio_analysis_info_card.dart @@ -171,13 +171,16 @@ class _AudioInfoCard extends StatelessWidget { value: _formatClipping(context, data.clippingSamples), cs: cs, ), - if (data.spectralCutoffHz != null) - _MetricChip( - icon: Icons.filter_alt_outlined, - label: context.l10n.audioAnalysisSpectralCutoff, - value: _formatFrequency(data.spectralCutoffHz!), - cs: cs, + _MetricChip( + icon: Icons.filter_alt_outlined, + label: context.l10n.audioAnalysisSpectralCutoff, + value: formatAudioAnalysisSpectralCutoff( + data.spectralCutoffHz, + notDetectedLabel: + context.l10n.audioAnalysisCutoffNotDetected, ), + cs: cs, + ), _MetricChip( icon: Icons.numbers, label: context.l10n.audioAnalysisSamples, @@ -228,11 +231,6 @@ class _AudioInfoCard extends StatelessWidget { return data.channels > 0 ? '${data.channels}' : 'N/A'; } - String _formatFrequency(double hz) { - if (hz >= 1000) return '${(hz / 1000).toStringAsFixed(1)} kHz'; - return '${hz.round()} Hz'; - } - String _formatBitrate(int bitsPerSecond) { if (bitsPerSecond >= 1000000) { return '${(bitsPerSecond / 1000000).toStringAsFixed(2)} Mbps'; diff --git a/lib/widgets/audio_analysis_models.dart b/lib/widgets/audio_analysis_models.dart index 4ff6e3a0..5270c98a 100644 --- a/lib/widgets/audio_analysis_models.dart +++ b/lib/widgets/audio_analysis_models.dart @@ -3,7 +3,7 @@ part of 'audio_analysis_widget.dart'; // Analysis result models and per-run parameter records. class AudioAnalysisData { - static const cacheVersion = 7; + static const cacheVersion = 8; final String filePath; final int fileSize; diff --git a/lib/widgets/audio_analysis_widget.dart b/lib/widgets/audio_analysis_widget.dart index 0fb10273..69eb015d 100644 --- a/lib/widgets/audio_analysis_widget.dart +++ b/lib/widgets/audio_analysis_widget.dart @@ -24,6 +24,19 @@ const int audioSpectrogramHeight = 800; const int audioSpectralAnalysisWidth = 400; const double audioSpectrogramDynamicRangeDb = 120; +String formatAudioAnalysisSpectralCutoff( + double? cutoffHz, { + required String notDetectedLabel, +}) { + if (cutoffHz == null || !cutoffHz.isFinite || cutoffHz <= 0) { + return notDetectedLabel; + } + if (cutoffHz >= 1000) { + return '${(cutoffHz / 1000).toStringAsFixed(1)} kHz'; + } + return '${cutoffHz.round()} Hz'; +} + String _buildShowspectrumOptions({required int width, required String color}) { return 'showspectrumpic=' 's=${width}x$audioSpectrogramHeight:' @@ -385,8 +398,10 @@ double? estimateEffectiveSpectralCutoffHz({ } // A genuinely broadband signal with no internal falling edge reaches the - // analysis ceiling. Report Nyquist only when both its baseband and top band - // are populated; silence or an isolated high-frequency line returns null. + // analysis ceiling. Natural music has a pronounced spectral tilt, so the + // top band does not need to be almost as loud as the baseband. It must still + // sit clearly above the measured low-level floor; silence or an isolated + // high-frequency line therefore continues to return null. final basebandLevel = _spectralMedian( smoothed, (height * 0.05).floor(), @@ -397,7 +412,8 @@ double? estimateEffectiveSpectralCutoffHz({ (height * 0.90).floor(), math.max(1, (height * 0.98).floor()), ); - if (basebandLevel >= 24 && topBandLevel >= basebandLevel - minimumDrop) { + final populatedTopFloor = math.max(24.0, lowLevel * 0.60); + if (basebandLevel >= 24 && topBandLevel >= populatedTopFloor) { return maxFrequencyHz; } return null; diff --git a/test/audio_analysis_info_card_test.dart b/test/audio_analysis_info_card_test.dart new file mode 100644 index 00000000..94566bbb --- /dev/null +++ b/test/audio_analysis_info_card_test.dart @@ -0,0 +1,26 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/widgets/audio_analysis_widget.dart'; + +void main() { + group('spectral cutoff formatting', () { + test('formats a detected cutoff frequency', () { + expect( + formatAudioAnalysisSpectralCutoff( + 22605, + notDetectedLabel: 'Not detected', + ), + '22.6 kHz', + ); + }); + + test('keeps the metric visible when no cutoff is detected', () { + expect( + formatAudioAnalysisSpectralCutoff( + null, + notDetectedLabel: 'Not detected', + ), + 'Not detected', + ); + }); + }); +} diff --git a/test/audio_analysis_spectrogram_test.dart b/test/audio_analysis_spectrogram_test.dart index 2365be8d..3dcbe7ab 100644 --- a/test/audio_analysis_spectrogram_test.dart +++ b/test/audio_analysis_spectrogram_test.dart @@ -250,6 +250,30 @@ lavfi.r128.true_peak=0.907 expect(cutoff, nyquist); }); + test( + 'reports Nyquist for full-band music with a natural spectral tilt', + () { + const cdNyquist = 22050.0; + final intensity = _blankIntensity(width, height); + _paintNaturalSpectralTilt( + intensity, + width: width, + height: height, + lowFrequencyIntensity: 140, + nyquistIntensity: 26, + ); + + final cutoff = estimateEffectiveSpectralCutoffHz( + intensity: intensity, + width: width, + height: height, + maxFrequencyHz: cdNyquist, + ); + + expect(cutoff, cdNyquist); + }, + ); + test('does not report an isolated line as a broadband cutoff', () { final intensity = _blankIntensity(width, height); _paintFrequencyBand( @@ -312,3 +336,24 @@ void _paintFrequencyBand( } } } + +void _paintNaturalSpectralTilt( + Uint8List values, { + required int width, + required int height, + required int lowFrequencyIntensity, + required int nyquistIntensity, +}) { + final span = lowFrequencyIntensity - nyquistIntensity; + for (var y = 0; y < height; y++) { + final normalizedFrequency = (height - y - 0.5) / height; + final intensity = + (lowFrequencyIntensity - + span * normalizedFrequency * normalizedFrequency) + .round() + .clamp(0, 255); + for (var x = 0; x < width; x++) { + values[y * width + x] = intensity; + } + } +}