diff --git a/lib/widgets/audio_analysis_models.dart b/lib/widgets/audio_analysis_models.dart index 6c74e3d9..782b6185 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 = 12; + static const cacheVersion = 13; final String filePath; final int fileSize; diff --git a/lib/widgets/audio_analysis_widget.dart b/lib/widgets/audio_analysis_widget.dart index badfdb09..b664c88a 100644 --- a/lib/widgets/audio_analysis_widget.dart +++ b/lib/widgets/audio_analysis_widget.dart @@ -23,7 +23,12 @@ const int audioSpectrogramWidth = 1600; const int audioSpectrogramHeight = 800; const int audioSpectralAnalysisWidth = 400; const double audioSpectrogramDynamicRangeDb = 120; -const int audioSpectrogramSampleWindowCount = 300; +// Keep splice boundaries well below the cutoff estimator's top temporal +// percentile. Too many short excerpts turn waveform discontinuities into a +// persistent broadband floor, which can make a real low-pass edge look like +// full-band content. Longer windows preserve the same whole-track coverage and +// memory bound while making the small number of seams statistical outliers. +const int audioSpectrogramSampleWindowCount = 16; const int audioSpectrogramMaxSelectedChannelSamples = 8 * 1024 * 1024; String formatAudioAnalysisSpectralCutoff( diff --git a/test/audio_analysis_spectrogram_test.dart b/test/audio_analysis_spectrogram_test.dart index 89859d34..6b5b1ab2 100644 --- a/test/audio_analysis_spectrogram_test.dart +++ b/test/audio_analysis_spectrogram_test.dart @@ -24,7 +24,7 @@ void main() { group('audio analysis cache', () { test('invalidates results from the previous cutoff estimator', () { - expect(AudioAnalysisData.cacheVersion, 12); + expect(AudioAnalysisData.cacheVersion, 13); }); }); @@ -142,14 +142,22 @@ lavfi.r128.true_peak=0.907 expect(arguments, isNot(contains('-loglevel'))); }); - test('bounds retained audio for long high-rate files', () { + test('bounds retained audio without making splice noise persistent', () { final filter = buildAudioSpectrogramFilter( durationSeconds: 600, sampleRate: 192000, channels: 2, ); - expect(filter, contains("aselect='lt(mod(t,2.000000000),")); + // The cutoff estimator uses temporal P90. Keep window boundaries far + // below ten percent of its 400 columns so seam energy remains an outlier. + expect(audioSpectrogramSampleWindowCount, 16); + expect( + audioSpectrogramSampleWindowCount, + lessThan(audioSpectralAnalysisWidth * 0.10), + ); + expect(filter, contains("aselect='lt(mod(t,37.500000000),")); + expect(filter, contains('1.365333333')); expect(filter, contains('asetpts=N/SR/TB')); expect(filter, contains('aformat=sample_fmts=fltp')); expect(filter, isNot(contains('aresample'))); @@ -246,6 +254,43 @@ lavfi.r128.true_peak=0.907 expect(cutoff!, inInclusiveRange(21000, 23500)); }); + test('ignores sparse broadband seams below the temporal P90 budget', () { + const sourceNyquist = 24000.0; + final intensity = _blankIntensity(width, height, value: 12); + _paintFrequencyBand( + intensity, + width: width, + height: height, + maxFrequencyHz: sourceNyquist, + lowHz: 0, + highHz: 20000, + intensity: 100, + ); + // Model discontinuities between distributed excerpts. Their columns are + // bright at every frequency, but occupy only six percent of the image. + _paintFrequencyBand( + intensity, + width: width, + height: height, + maxFrequencyHz: sourceNyquist, + lowHz: 0, + highHz: sourceNyquist, + intensity: 255, + startColumn: 0, + endColumn: 12, + ); + + final cutoff = estimateEffectiveSpectralCutoffHz( + intensity: intensity, + width: width, + height: height, + maxFrequencyHz: sourceNyquist, + ); + + expect(cutoff, isNotNull); + expect(cutoff!, inInclusiveRange(19400, 20600)); + }); + test('rejects a low noise floor above a 15.8 kHz bandwidth edge', () { const cdNyquist = 22050.0; final intensity = _blankIntensity(width, height, value: 42);