mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-03 16:50:40 +02:00
fix(analysis): reject false spectral rolloff cutoffs
This commit is contained in:
@@ -3,7 +3,7 @@ part of 'audio_analysis_widget.dart';
|
||||
// Analysis result models and per-run parameter records.
|
||||
|
||||
class AudioAnalysisData {
|
||||
static const cacheVersion = 10;
|
||||
static const cacheVersion = 12;
|
||||
|
||||
final String filePath;
|
||||
final int fileSize;
|
||||
|
||||
@@ -381,6 +381,24 @@ double? estimateEffectiveSpectralCutoffHz({
|
||||
}
|
||||
|
||||
final hzPerRow = maxFrequencyHz / height;
|
||||
|
||||
// Reject narrow horizontal lines before looking for a bandwidth edge. A
|
||||
// median over roughly 500 Hz preserves a broadband step while removing
|
||||
// pilots, tones, and isolated noisy bins that occupy only a small fraction
|
||||
// of the surrounding band.
|
||||
final lineRejectionRadius = math.max(1, (250 / hzPerRow).ceil());
|
||||
final broadbandProfile = Float64List(height);
|
||||
for (var index = 0; index < height; index++) {
|
||||
broadbandProfile[index] = _spectralMedian(
|
||||
profile,
|
||||
index - lineRejectionRadius,
|
||||
index + lineRejectionRadius + 1,
|
||||
);
|
||||
}
|
||||
|
||||
// Apply only light smoothing after the robust frequency aggregation. This
|
||||
// reduces row quantization without letting a high-amplitude line smear into
|
||||
// enough adjacent bins to resemble broadband support.
|
||||
final smoothingRadius = math.max(1, (50 / hzPerRow).ceil());
|
||||
final smoothed = Float64List(height);
|
||||
var running = 0.0;
|
||||
@@ -391,10 +409,10 @@ double? estimateEffectiveSpectralCutoffHz({
|
||||
final desiredEnd = math.min(height - 1, index + smoothingRadius);
|
||||
while (windowEnd < desiredEnd) {
|
||||
windowEnd++;
|
||||
running += profile[windowEnd];
|
||||
running += broadbandProfile[windowEnd];
|
||||
}
|
||||
while (windowStart < desiredStart) {
|
||||
running -= profile[windowStart];
|
||||
running -= broadbandProfile[windowStart];
|
||||
windowStart++;
|
||||
}
|
||||
smoothed[index] = running / (windowEnd - windowStart + 1);
|
||||
@@ -437,7 +455,11 @@ double? estimateEffectiveSpectralCutoffHz({
|
||||
return bDrop.compareTo(aDrop);
|
||||
});
|
||||
|
||||
final minimumDrop = math.max(12.0, dynamicSpan * 0.18);
|
||||
// A sharp, frequency-contiguous edge remains meaningful at lower contrast
|
||||
// than an arbitrary bright bin. Keep the threshold relative to the measured
|
||||
// spectral span, but do not require the old fixed 12-byte difference that
|
||||
// caused elevated noise floors to be classified as full-band.
|
||||
final minimumDrop = math.max(6.0, dynamicSpan * 0.18);
|
||||
final gapRows = math.max(1, (100 / hzPerRow).ceil());
|
||||
final supportRows = math.max(3, (1200 / hzPerRow).ceil());
|
||||
final stableTailSpread = math.max(4.0, dynamicSpan * 0.06);
|
||||
@@ -478,7 +500,11 @@ double? estimateEffectiveSpectralCutoffHz({
|
||||
(height * 0.90).floor(),
|
||||
math.max(1, (height * 0.98).floor()),
|
||||
);
|
||||
final activeMargin = math.max(10.0, dynamicSpan * 0.20);
|
||||
// A gradual cutoff needs stronger contrast than a sharp, contiguous edge.
|
||||
// Otherwise an ordinary full-band spectral tilt can eventually cross the
|
||||
// high-frequency floor by a few grayscale steps and create an arbitrary
|
||||
// cutoff. Require a material transition into the stable tail instead.
|
||||
final activeMargin = math.max(12.0, dynamicSpan * 0.25);
|
||||
final activeThreshold = tailReferenceLevel + activeMargin;
|
||||
for (var edgeIndex = searchEnd - 1; edgeIndex >= searchStart; edgeIndex--) {
|
||||
final belowEnd = edgeIndex - gapRows;
|
||||
@@ -519,8 +545,13 @@ double? estimateEffectiveSpectralCutoffHz({
|
||||
math.max(1, (height * 0.90).floor()),
|
||||
);
|
||||
final topBandNearPeak = topBandLevel >= highLevel - minimumDrop;
|
||||
// A real low-contrast full-band slope can change by less than the tail
|
||||
// stability tolerance. Preserve it when the upper octave still trends
|
||||
// downward by at least one grayscale step; a low-pass noise plateau remains
|
||||
// flat here and must already have passed the validated edge checks above.
|
||||
final upperBandSlope = lowerTopBandLevel - topBandLevel;
|
||||
final topBandStillSloping =
|
||||
lowerTopBandLevel - topBandLevel > stableTailSpread;
|
||||
upperBandSlope >= math.max(1.0, dynamicSpan * 0.05);
|
||||
if (basebandLevel >= 24 &&
|
||||
topBandLevel >= 24 &&
|
||||
(topBandNearPeak || topBandStillSloping)) {
|
||||
|
||||
@@ -22,6 +22,12 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('audio analysis cache', () {
|
||||
test('invalidates results from the previous cutoff estimator', () {
|
||||
expect(AudioAnalysisData.cacheVersion, 12);
|
||||
});
|
||||
});
|
||||
|
||||
group('audio level analysis', () {
|
||||
test('reads peak and RMS from the final astats overall summary', () {
|
||||
const logs = '''
|
||||
@@ -275,6 +281,66 @@ lavfi.r128.true_peak=0.907
|
||||
expect(cutoff!, inInclusiveRange(15300, 16300));
|
||||
});
|
||||
|
||||
test(
|
||||
'finds a 15 kHz edge below elevated noise and a persistent 18.7 kHz line',
|
||||
() {
|
||||
const cdNyquist = 22050.0;
|
||||
final intensity = _blankIntensity(width, height, value: 30);
|
||||
_paintFrequencyBand(
|
||||
intensity,
|
||||
width: width,
|
||||
height: height,
|
||||
maxFrequencyHz: cdNyquist,
|
||||
lowHz: 0,
|
||||
highHz: 15000,
|
||||
intensity: 39,
|
||||
);
|
||||
_paintFrequencyBand(
|
||||
intensity,
|
||||
width: width,
|
||||
height: height,
|
||||
maxFrequencyHz: cdNyquist,
|
||||
lowHz: 18600,
|
||||
highHz: 18800,
|
||||
intensity: 220,
|
||||
);
|
||||
|
||||
final cutoff = estimateEffectiveSpectralCutoffHz(
|
||||
intensity: intensity,
|
||||
width: width,
|
||||
height: height,
|
||||
maxFrequencyHz: cdNyquist,
|
||||
);
|
||||
|
||||
expect(cutoff, isNotNull);
|
||||
expect(cutoff!, inInclusiveRange(14500, 15500));
|
||||
},
|
||||
);
|
||||
|
||||
test('retains a genuine broadband cutoff around 18.7 kHz', () {
|
||||
const cdNyquist = 22050.0;
|
||||
final intensity = _blankIntensity(width, height, value: 18);
|
||||
_paintFrequencyBand(
|
||||
intensity,
|
||||
width: width,
|
||||
height: height,
|
||||
maxFrequencyHz: cdNyquist,
|
||||
lowHz: 0,
|
||||
highHz: 18700,
|
||||
intensity: 100,
|
||||
);
|
||||
|
||||
final cutoff = estimateEffectiveSpectralCutoffHz(
|
||||
intensity: intensity,
|
||||
width: width,
|
||||
height: height,
|
||||
maxFrequencyHz: cdNyquist,
|
||||
);
|
||||
|
||||
expect(cutoff, isNotNull);
|
||||
expect(cutoff!, inInclusiveRange(18200, 19200));
|
||||
});
|
||||
|
||||
test('ignores a tonal drop before a gradual 22 kHz bandwidth limit', () {
|
||||
const hiresNyquist = 48000.0;
|
||||
final intensity = _blankIntensity(width, height, value: 46);
|
||||
@@ -379,6 +445,65 @@ lavfi.r128.true_peak=0.907
|
||||
},
|
||||
);
|
||||
|
||||
test('reports Nyquist for low-contrast full-band spectral tilt', () {
|
||||
const cdNyquist = 22050.0;
|
||||
final intensity = _blankIntensity(width, height);
|
||||
_paintNaturalSpectralTilt(
|
||||
intensity,
|
||||
width: width,
|
||||
height: height,
|
||||
lowFrequencyIntensity: 39,
|
||||
nyquistIntensity: 30,
|
||||
);
|
||||
|
||||
final cutoff = estimateEffectiveSpectralCutoffHz(
|
||||
intensity: intensity,
|
||||
width: width,
|
||||
height: height,
|
||||
maxFrequencyHz: cdNyquist,
|
||||
);
|
||||
|
||||
expect(cutoff, cdNyquist);
|
||||
});
|
||||
|
||||
test('reports Nyquist for an extended gentle high-frequency rolloff', () {
|
||||
const cdNyquist = 22050.0;
|
||||
final intensity = _blankIntensity(width, height);
|
||||
const segments = <(double, double, int, int)>[
|
||||
(0, 4000, 113, 104),
|
||||
(4000, 6000, 104, 96),
|
||||
(6000, 15000, 96, 91),
|
||||
(15000, 16000, 91, 87),
|
||||
(16000, 17000, 87, 84),
|
||||
(17000, 18500, 84, 82),
|
||||
(18500, 19000, 82, 78),
|
||||
(19000, 20000, 78, 73),
|
||||
(20000, 21000, 73, 70),
|
||||
(21000, cdNyquist, 70, 69),
|
||||
];
|
||||
for (final segment in segments) {
|
||||
_paintFrequencySlope(
|
||||
intensity,
|
||||
width: width,
|
||||
height: height,
|
||||
maxFrequencyHz: cdNyquist,
|
||||
lowHz: segment.$1,
|
||||
highHz: segment.$2,
|
||||
lowIntensity: segment.$3,
|
||||
highIntensity: segment.$4,
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user