fix(analysis): reject false spectral rolloff cutoffs

This commit is contained in:
zarzet
2026-08-28 00:33:55 +07:00
parent 862037a9d5
commit 847fc725b6
3 changed files with 162 additions and 6 deletions
+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 = 10;
static const cacheVersion = 12;
final String filePath;
final int fileSize;
+36 -5
View File
@@ -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)) {
+125
View File
@@ -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(