mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-02 09:08:35 +02:00
fix(analysis): detect full-band spectral cutoff
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -3453,6 +3453,9 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get audioAnalysisSpectralCutoff => '주파수 컷오프';
|
||||
|
||||
@override
|
||||
String get audioAnalysisCutoffNotDetected => 'Not detected';
|
||||
|
||||
@override
|
||||
String get audioAnalysisChannelStats => '채널별 통계';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user