diff --git a/lib/widgets/audio_analysis_models.dart b/lib/widgets/audio_analysis_models.dart index 7a0844e7..a5418b55 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 = 9; + static const cacheVersion = 10; final String filePath; final int fileSize; diff --git a/lib/widgets/audio_analysis_widget.dart b/lib/widgets/audio_analysis_widget.dart index b7f84778..5af60745 100644 --- a/lib/widgets/audio_analysis_widget.dart +++ b/lib/widgets/audio_analysis_widget.dart @@ -438,13 +438,14 @@ double? estimateEffectiveSpectralCutoffHz({ }); final minimumDrop = math.max(12.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); for (final bestStart in candidateStarts) { final bestLocalDrop = smoothed[bestStart] - smoothed[bestStart + edgeSpanRows]; if (bestLocalDrop < minimumDrop * 0.60) break; final edgeIndex = (bestStart + edgeSpanRows / 2).round(); - final gapRows = math.max(1, (100 / hzPerRow).ceil()); - final supportRows = math.max(3, (1200 / hzPerRow).ceil()); final belowEnd = edgeIndex - gapRows; final belowStart = math.max(0, belowEnd - supportRows); final aboveStart = edgeIndex + gapRows; @@ -453,22 +454,55 @@ double? estimateEffectiveSpectralCutoffHz({ final belowLevel = _spectralMedian(smoothed, belowStart, belowEnd); final aboveLevel = _spectralMedian(smoothed, aboveStart, aboveEnd); final tailLevel = _spectralMedian(smoothed, aboveStart, height); + final tailSpread = + _spectralPercentile(smoothed, aboveStart, height, 0.80) - + _spectralPercentile(smoothed, aboveStart, height, 0.20); final baseStart = math.max(0, edgeIndex - (3000 / hzPerRow).ceil()); final baseLevel = _spectralMedian(smoothed, baseStart, belowEnd); if (belowLevel - aboveLevel >= minimumDrop && belowLevel - tailLevel >= minimumDrop && - baseLevel - tailLevel >= minimumDrop) { + baseLevel - tailLevel >= minimumDrop && + tailSpread <= stableTailSpread) { final cutoff = (edgeIndex + 0.5) * hzPerRow; return cutoff.clamp(0.0, maxFrequencyHz).toDouble(); } } } - // A genuinely broadband signal with no internal falling edge reaches the - // 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. + // Some masters roll off over several kilohertz instead of ending at one + // sharp edge. Find the highest sustained band that remains clearly above a + // stable high-frequency floor so ordinary musical spectral tilt is not + // mistaken for a low-pass cutoff. + final tailReferenceLevel = _spectralMedian( + smoothed, + (height * 0.90).floor(), + math.max(1, (height * 0.98).floor()), + ); + final activeMargin = math.max(10.0, dynamicSpan * 0.20); + final activeThreshold = tailReferenceLevel + activeMargin; + for (var edgeIndex = searchEnd - 1; edgeIndex >= searchStart; edgeIndex--) { + final belowEnd = edgeIndex - gapRows; + final belowStart = math.max(0, belowEnd - supportRows); + final aboveStart = edgeIndex + gapRows; + if (belowEnd <= belowStart || aboveStart >= height) continue; + final belowLevel = _spectralMedian(smoothed, belowStart, belowEnd); + if (belowLevel < activeThreshold) continue; + final tailLevel = _spectralMedian(smoothed, aboveStart, height); + final tailSpread = + _spectralPercentile(smoothed, aboveStart, height, 0.80) - + _spectralPercentile(smoothed, aboveStart, height, 0.20); + if (belowLevel - tailLevel >= activeMargin && + tailSpread <= stableTailSpread) { + final cutoffIndex = edgeIndex - supportRows / 2; + final cutoff = (cutoffIndex + 0.5) * hzPerRow; + return cutoff.clamp(0.0, maxFrequencyHz).toDouble(); + } + } + + // A genuinely broadband signal with no stable tail plateau reaches the + // analysis ceiling. Natural music may retain a pronounced spectral tilt, + // so require populated baseband and top bands instead of similar levels. + // Silence and isolated high-frequency lines still return null. final basebandLevel = _spectralMedian( smoothed, (height * 0.05).floor(), @@ -479,8 +513,17 @@ double? estimateEffectiveSpectralCutoffHz({ (height * 0.90).floor(), math.max(1, (height * 0.98).floor()), ); - final populatedTopFloor = math.max(24.0, lowLevel * 0.60); - if (basebandLevel >= 24 && topBandLevel >= populatedTopFloor) { + final lowerTopBandLevel = _spectralMedian( + smoothed, + (height * 0.80).floor(), + math.max(1, (height * 0.90).floor()), + ); + final topBandNearPeak = topBandLevel >= highLevel - minimumDrop; + final topBandStillSloping = + lowerTopBandLevel - topBandLevel > stableTailSpread; + if (basebandLevel >= 24 && + topBandLevel >= 24 && + (topBandNearPeak || topBandStillSloping)) { return maxFrequencyHz; } return null; diff --git a/test/audio_analysis_spectrogram_test.dart b/test/audio_analysis_spectrogram_test.dart index 03499599..c51fc9e0 100644 --- a/test/audio_analysis_spectrogram_test.dart +++ b/test/audio_analysis_spectrogram_test.dart @@ -275,6 +275,50 @@ lavfi.r128.true_peak=0.907 expect(cutoff!, inInclusiveRange(15300, 16300)); }); + test('ignores a tonal drop before a gradual 22 kHz bandwidth limit', () { + const hiresNyquist = 48000.0; + final intensity = _blankIntensity(width, height, value: 46); + _paintFrequencyBand( + intensity, + width: width, + height: height, + maxFrequencyHz: hiresNyquist, + lowHz: 0, + highHz: 4000, + intensity: 120, + ); + _paintFrequencySlope( + intensity, + width: width, + height: height, + maxFrequencyHz: hiresNyquist, + lowHz: 4000, + highHz: 20000, + lowIntensity: 115, + highIntensity: 75, + ); + _paintFrequencySlope( + intensity, + width: width, + height: height, + maxFrequencyHz: hiresNyquist, + lowHz: 20000, + highHz: 23500, + lowIntensity: 75, + highIntensity: 46, + ); + + final cutoff = estimateEffectiveSpectralCutoffHz( + intensity: intensity, + width: width, + height: height, + maxFrequencyHz: hiresNyquist, + ); + + expect(cutoff, isNotNull); + expect(cutoff!, inInclusiveRange(20500, 22500)); + }); + test('retains genuine broadband ultrasonic content', () { final intensity = _blankIntensity(width, height, value: 10); _paintFrequencyBand( @@ -418,3 +462,27 @@ void _paintNaturalSpectralTilt( } } } + +void _paintFrequencySlope( + Uint8List values, { + required int width, + required int height, + required double maxFrequencyHz, + required double lowHz, + required double highHz, + required int lowIntensity, + required int highIntensity, +}) { + final frequencySpan = highHz - lowHz; + for (var y = 0; y < height; y++) { + final frequency = (height - y - 0.5) / height * maxFrequencyHz; + if (frequency < lowHz || frequency > highHz) continue; + final progress = (frequency - lowHz) / frequencySpan; + final intensity = (lowIntensity + (highIntensity - lowIntensity) * progress) + .round() + .clamp(0, 255); + for (var x = 0; x < width; x++) { + values[y * width + x] = intensity; + } + } +}