diff --git a/lib/widgets/audio_analysis_models.dart b/lib/widgets/audio_analysis_models.dart index b4e6645b..7a0844e7 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 = 8; + static const cacheVersion = 9; final String filePath; final int fileSize; diff --git a/lib/widgets/audio_analysis_widget.dart b/lib/widgets/audio_analysis_widget.dart index 8d95084e..b7f84778 100644 --- a/lib/widgets/audio_analysis_widget.dart +++ b/lib/widgets/audio_analysis_widget.dart @@ -23,6 +23,8 @@ const int audioSpectrogramWidth = 1600; const int audioSpectrogramHeight = 800; const int audioSpectralAnalysisWidth = 400; const double audioSpectrogramDynamicRangeDb = 120; +const int audioSpectrogramSampleWindowCount = 300; +const int audioSpectrogramMaxSelectedChannelSamples = 8 * 1024 * 1024; String formatAudioAnalysisSpectralCutoff( double? cutoffHz, { @@ -71,9 +73,19 @@ String _buildShowspectrumOptions({required int width, required String color}) { String buildAudioSpectrogramFilter({ int channel = -1, bool includeCutoffPlane = false, + double? durationSeconds, + int? sampleRate, + int? channels, }) { final channelFilter = channel >= 0 ? 'pan=mono|c0=c$channel,' : ''; - final input = '[0:a:0]${channelFilter}aformat=sample_fmts=fltp'; + final samplingFilter = _buildAudioSpectrogramSamplingFilter( + channel: channel, + durationSeconds: durationSeconds, + sampleRate: sampleRate, + channels: channels, + ); + final input = + '[0:a:0]$channelFilter${samplingFilter}aformat=sample_fmts=fltp'; final display = _buildShowspectrumOptions( width: audioSpectrogramWidth, color: 'intensity', @@ -100,6 +112,9 @@ List buildAudioSpectrogramArguments({ required String outputPath, String? cutoffOutputPath, int channel = -1, + double? durationSeconds, + int? sampleRate, + int? channels, }) { final arguments = [ '-hide_banner', @@ -110,6 +125,9 @@ List buildAudioSpectrogramArguments({ buildAudioSpectrogramFilter( channel: channel, includeCutoffPlane: cutoffOutputPath != null, + durationSeconds: durationSeconds, + sampleRate: sampleRate, + channels: channels, ), '-map', '[spectrum]', @@ -137,6 +155,32 @@ List buildAudioSpectrogramArguments({ return arguments; } +String _buildAudioSpectrogramSamplingFilter({ + required int channel, + required double? durationSeconds, + required int? sampleRate, + required int? channels, +}) { + final duration = durationSeconds ?? 0; + final rate = sampleRate ?? 0; + if (!duration.isFinite || duration <= 0 || rate <= 0) return ''; + + final selectedChannels = channel >= 0 + ? 1 + : ((channels ?? 0) > 0 ? channels! : 2); + final maxSelectedDuration = + audioSpectrogramMaxSelectedChannelSamples / (rate * selectedChannels); + if (duration <= maxSelectedDuration) return ''; + + // showspectrumpic retains its complete input until it can render the final + // frame. Feed it equal windows spread across the whole track so memory is + // bounded without biasing the image or cutoff estimate toward the intro. + final interval = duration / audioSpectrogramSampleWindowCount; + final window = maxSelectedDuration / audioSpectrogramSampleWindowCount; + return "aselect='lt(mod(t,${interval.toStringAsFixed(9)})," + "${window.toStringAsFixed(9)})',asetpts=N/SR/TB,"; +} + class AudioAstatsSummary { final double peakDb; final double rmsDb; @@ -583,7 +627,10 @@ class _AudioAnalysisCardState extends State { expectedPath, channel: _spectrogramChannel, ); - image ??= await _generateAndCacheSpectrogram(filePath: expectedPath); + image ??= await _generateAndCacheSpectrogram( + filePath: expectedPath, + analysisData: cached, + ); if (isCurrentRequest()) { setState(() { _spectrogramImage?.dispose(); @@ -600,11 +647,18 @@ class _AudioAnalysisCardState extends State { } } - Future _generateAndCacheSpectrogram({String? filePath}) async { + Future _generateAndCacheSpectrogram({ + String? filePath, + AudioAnalysisData? analysisData, + }) async { final sourcePath = filePath ?? widget.filePath; + final data = analysisData ?? _data; final artifact = await _generateSpectrogramForFile( sourcePath, channel: _spectrogramChannel, + durationSeconds: data?.duration, + sampleRate: data?.sampleRate, + channels: data?.channels, ); await _saveSpectrogramToCache( sourcePath, @@ -662,7 +716,7 @@ class _AudioAnalysisCardState extends State { ); } - image ??= await _generateAndCacheSpectrogram(); + image ??= await _generateAndCacheSpectrogram(analysisData: data); if (mounted) { setState(() { @@ -833,10 +887,16 @@ class _AudioAnalysisCardState extends State { } _GeneratedSpectrogram? spectrogram; try { + final effectiveDuration = info.totalSamples > 0 && info.sampleRate > 0 + ? info.totalSamples / info.sampleRate + : info.duration; spectrogram = await _generateSpectrogram( workingPath, channel: -1, includeCutoffPlane: true, + durationSeconds: effectiveDuration, + sampleRate: info.sampleRate, + channels: info.channels, ); final cutoffIntensity = spectrogram.cutoffIntensity; if (cutoffIntensity == null) { @@ -851,9 +911,6 @@ class _AudioAnalysisCardState extends State { maxFrequencyHz: info.sampleRate / 2, ), ); - final effectiveDuration = info.totalSamples > 0 && info.sampleRate > 0 - ? info.totalSamples / info.sampleRate - : info.duration; final levelMetrics = await _runFullStreamLevelAnalysis( workingPath, durationSeconds: effectiveDuration, @@ -909,6 +966,9 @@ class _AudioAnalysisCardState extends State { Future<_GeneratedSpectrogram> _generateSpectrogramForFile( String filePath, { required int channel, + double? durationSeconds, + int? sampleRate, + int? channels, }) async { String workingPath = filePath; String? tempCopy; @@ -921,7 +981,13 @@ class _AudioAnalysisCardState extends State { } try { - return await _generateSpectrogram(workingPath, channel: channel); + return await _generateSpectrogram( + workingPath, + channel: channel, + durationSeconds: durationSeconds, + sampleRate: sampleRate, + channels: channels, + ); } finally { if (tempCopy != null) { try { @@ -935,6 +1001,9 @@ class _AudioAnalysisCardState extends State { String inputPath, { required int channel, bool includeCutoffPlane = false, + double? durationSeconds, + int? sampleRate, + int? channels, }) async { final tempDir = await getTemporaryDirectory(); final rawPath = @@ -949,6 +1018,9 @@ class _AudioAnalysisCardState extends State { outputPath: rawPath, cutoffOutputPath: cutoffPath, channel: channel, + durationSeconds: durationSeconds, + sampleRate: sampleRate, + channels: channels, ), ); @@ -1035,6 +1107,9 @@ class _AudioAnalysisCardState extends State { final artifact = await _generateSpectrogramForFile( widget.filePath, channel: channel, + durationSeconds: data.duration, + sampleRate: data.sampleRate, + channels: data.channels, ); image = artifact.image; await _saveSpectrogramToCache(widget.filePath, image, channel: channel); diff --git a/test/audio_analysis_spectrogram_test.dart b/test/audio_analysis_spectrogram_test.dart index bbde1b7c..03499599 100644 --- a/test/audio_analysis_spectrogram_test.dart +++ b/test/audio_analysis_spectrogram_test.dart @@ -136,6 +136,49 @@ lavfi.r128.true_peak=0.907 expect(arguments, isNot(contains('-loglevel'))); }); + test('bounds retained audio for long high-rate files', () { + final filter = buildAudioSpectrogramFilter( + durationSeconds: 600, + sampleRate: 192000, + channels: 2, + ); + + expect(filter, contains("aselect='lt(mod(t,2.000000000),")); + expect(filter, contains('asetpts=N/SR/TB')); + expect(filter, contains('aformat=sample_fmts=fltp')); + expect(filter, isNot(contains('aresample'))); + }); + + test('keeps short files continuous', () { + final filter = buildAudioSpectrogramFilter( + durationSeconds: 60, + sampleRate: 44100, + channels: 2, + ); + + expect(filter, isNot(contains('aselect='))); + expect(filter, isNot(contains('asetpts='))); + }); + + test('accounts for a selected mono channel in the memory bound', () { + final combined = buildAudioSpectrogramFilter( + durationSeconds: 60, + sampleRate: 192000, + channels: 8, + ); + final mono = buildAudioSpectrogramFilter( + channel: 3, + durationSeconds: 60, + sampleRate: 192000, + channels: 8, + ); + + expect(combined, contains('aselect=')); + expect(mono, contains('pan=mono|c0=c3')); + expect(mono, contains('aselect=')); + expect(combined, isNot(equals(mono))); + }); + test('renders a monotonic cutoff plane beside the display image', () { final arguments = buildAudioSpectrogramArguments( inputPath: 'source.flac',