fix(analysis): detect full-band spectral cutoff

This commit is contained in:
zarzet
2026-07-30 15:14:36 +07:00
parent 5357e14b06
commit d85c8f6a17
19 changed files with 144 additions and 15 deletions
+6
View File
@@ -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:
+3
View File
@@ -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';
+3
View File
@@ -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';
+3
View File
@@ -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';
+3
View File
@@ -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';
+3
View File
@@ -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';
+3
View File
@@ -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';
+3
View File
@@ -3453,6 +3453,9 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get audioAnalysisSpectralCutoff => '주파수 컷오프';
@override
String get audioAnalysisCutoffNotDetected => 'Not detected';
@override
String get audioAnalysisChannelStats => '채널별 통계';
+3
View File
@@ -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';
+3
View File
@@ -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';
+3
View File
@@ -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';
+3
View File
@@ -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';
+4
View File
@@ -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"
+1
View File
@@ -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"
+9 -11
View File
@@ -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';
+1 -1
View File
@@ -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;
+19 -3
View File
@@ -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;
+26
View File
@@ -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',
);
});
});
}
+45
View File
@@ -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;
}
}
}