diff --git a/lib/widgets/audio_analysis_models.dart b/lib/widgets/audio_analysis_models.dart index a5418b55..6c74e3d9 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 = 10; + static const cacheVersion = 12; final String filePath; final int fileSize; diff --git a/lib/widgets/audio_analysis_widget.dart b/lib/widgets/audio_analysis_widget.dart index 92e2b291..badfdb09 100644 --- a/lib/widgets/audio_analysis_widget.dart +++ b/lib/widgets/audio_analysis_widget.dart @@ -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)) { diff --git a/test/audio_analysis_spectrogram_test.dart b/test/audio_analysis_spectrogram_test.dart index c51fc9e0..89859d34 100644 --- a/test/audio_analysis_spectrogram_test.dart +++ b/test/audio_analysis_spectrogram_test.dart @@ -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(