From 34a004f96f064d8aead16aeb08ed7b76803a66db Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Mon, 19 Oct 2026 00:43:26 +0700 Subject: [PATCH] refactor(analysis): move the pure FFmpeg builders and parsers to a service The audio analysis card mixed FFmpeg argument builders, astats and metadata parsers and the spectral cutoff estimator with widget state. Move them verbatim into lib/services/audio_analysis_ffmpeg.dart so the runner and cache can depend on them without importing a widget. The parsers return ChannelAnalysisStats, so the two public result models (AudioAnalysisData, ChannelAnalysisStats) move verbatim from the widget's models part into lib/services/audio_analysis_models.dart. The per-run private records stay in the part for now. Only two names change: the unsupported-codec exception loses its leading underscore because the widget and the future runner live in different libraries, and the in-class _parseChannelStats becomes the top-level parseChannelStats. The widget re-exports both service files, so tests and importers are unchanged. --- lib/services/audio_analysis_ffmpeg.dart | 616 +++++++++++++++++++++++ lib/services/audio_analysis_models.dart | 141 ++++++ lib/widgets/audio_analysis_models.dart | 143 +----- lib/widgets/audio_analysis_widget.dart | 626 +----------------------- 4 files changed, 768 insertions(+), 758 deletions(-) create mode 100644 lib/services/audio_analysis_ffmpeg.dart create mode 100644 lib/services/audio_analysis_models.dart diff --git a/lib/services/audio_analysis_ffmpeg.dart b/lib/services/audio_analysis_ffmpeg.dart new file mode 100644 index 00000000..2db47764 --- /dev/null +++ b/lib/services/audio_analysis_ffmpeg.dart @@ -0,0 +1,616 @@ +import 'dart:math' as math; +import 'dart:typed_data'; + +import 'package:spotiflac_android/services/audio_analysis_models.dart'; + +// Pure FFmpeg argument builders, log/metadata parsers and the spectral +// cutoff estimator used by the audio analysis runner. + +const int audioSpectrogramWidth = 1600; +const int audioSpectrogramHeight = 800; +const int audioSpectralAnalysisWidth = 400; +const double audioSpectrogramDynamicRangeDb = 120; +// 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( + double? cutoffHz, { + required String notDetectedLabel, +}) { + if (cutoffHz == null || !cutoffHz.isFinite || cutoffHz <= 0) { + return notDetectedLabel; + } + if (cutoffHz >= 1000) { + return '${(cutoffHz / 1000).toStringAsFixed(1)} kHz'; + } + return '${cutoffHz.round()} Hz'; +} + +final RegExp _ac4CodecTokenPattern = RegExp( + r'(^|[^a-z0-9])ac[\s_-]?4($|[^a-z0-9])', + caseSensitive: false, +); + +/// Returns whether the bundled FFmpeg decoder can analyze this audio codec. +/// Container formats such as MP4 are intentionally not used here because the +/// same container may hold supported AAC/ALAC/E-AC-3 or unsupported AC-4. +bool isAudioAnalysisCodecSupported(String? codecName) { + final value = codecName?.trim() ?? ''; + if (value.isEmpty) return true; + return !_ac4CodecTokenPattern.hasMatch(value); +} + +String? unsupportedAudioAnalysisCodecLabel(String? codecName) => + isAudioAnalysisCodecSupported(codecName) ? null : 'AC-4'; + +class UnsupportedAudioAnalysisCodecException implements Exception { + final String codecLabel; + + const UnsupportedAudioAnalysisCodecException(this.codecLabel); +} + +String _buildShowspectrumOptions({required int width, required String color}) { + return 'showspectrumpic=' + 's=${width}x$audioSpectrogramHeight:' + 'legend=0:mode=combined:color=$color:scale=log:fscale=lin:' + 'win_func=hann:drange=${audioSpectrogramDynamicRangeDb.toStringAsFixed(0)}:' + 'limit=0'; +} + +String buildAudioSpectrogramFilter({ + int channel = -1, + bool includeCutoffPlane = false, + double? durationSeconds, + int? sampleRate, + int? channels, +}) { + final channelFilter = channel >= 0 ? 'pan=mono|c0=c$channel,' : ''; + 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', + ); + if (!includeCutoffPlane) { + return '$input,$display,format=rgba[spectrum]'; + } + + // The display palette encodes quiet bins as saturated blue/purple pixels, + // so RGB brightness is not a monotonic measure of spectral magnitude. Keep + // the colorful UI output, but generate a small fixed-hue plane whose gray + // values can be used safely for effective-bandwidth detection. + final cutoff = _buildShowspectrumOptions( + width: audioSpectralAnalysisWidth, + color: 'green', + ); + return '$input,asplit=2[display_input][cutoff_input];' + '[display_input]$display,format=rgba[spectrum];' + '[cutoff_input]$cutoff,format=gray[cutoff]'; +} + +List buildAudioSpectrogramArguments({ + required String inputPath, + required String outputPath, + String? cutoffOutputPath, + int channel = -1, + double? durationSeconds, + int? sampleRate, + int? channels, +}) { + final arguments = [ + '-hide_banner', + '-y', + '-i', + inputPath, + '-filter_complex', + buildAudioSpectrogramFilter( + channel: channel, + includeCutoffPlane: cutoffOutputPath != null, + durationSeconds: durationSeconds, + sampleRate: sampleRate, + channels: channels, + ), + '-map', + '[spectrum]', + '-frames:v', + '1', + '-f', + 'rawvideo', + '-pix_fmt', + 'rgba', + outputPath, + ]; + if (cutoffOutputPath != null) { + arguments.addAll([ + '-map', + '[cutoff]', + '-frames:v', + '1', + '-f', + 'rawvideo', + '-pix_fmt', + 'gray', + cutoffOutputPath, + ]); + } + 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; + + const AudioAstatsSummary({required this.peakDb, required this.rmsDb}); +} + +class AudioAnalysisMetadataSummary extends AudioAstatsSummary { + final double? integratedLufs; + final double? truePeakDb; + final List channelStats; + + const AudioAnalysisMetadataSummary({ + required super.peakDb, + required super.rmsDb, + this.integratedLufs, + this.truePeakDb, + this.channelStats = const [], + }); +} + +AudioAstatsSummary? parseAudioAstatsSummary(String logs) { + final overallMatch = RegExp(r'Overall([\s\S]*)').firstMatch(logs); + final section = overallMatch?.group(1) ?? logs; + final peak = _parseLastAudioAstatsValue(section, 'Peak level dB'); + final rms = _parseLastAudioAstatsValue(section, 'RMS level dB'); + if (peak == null || rms == null) return null; + return AudioAstatsSummary(peakDb: peak, rmsDb: rms); +} + +double? _parseLastAudioAstatsValue(String text, String label) { + final matches = RegExp( + '${RegExp.escape(label)}:\\s*([-+]?\\d+(?:\\.\\d+)?)', + caseSensitive: false, + ).allMatches(text); + double? value; + for (final match in matches) { + final parsed = double.tryParse(match.group(1) ?? ''); + if (parsed != null && parsed.isFinite) { + value = parsed; + } + } + return value; +} + +List parseChannelStats(String logs) { + final stats = []; + final channelMatches = RegExp( + r'Channel:\s*(\d+)([\s\S]*?)(?=Channel:\s*\d+|Overall|$)', + caseSensitive: false, + ).allMatches(logs); + + for (final match in channelMatches) { + final channel = int.tryParse(match.group(1) ?? '') ?? 0; + final section = match.group(2) ?? ''; + if (channel <= 0 || section.trim().isEmpty) continue; + final peakDb = _parseLastAstatsValue(section, 'Peak level dB'); + final rmsDb = _parseLastAstatsValue(section, 'RMS level dB'); + stats.add( + ChannelAnalysisStats( + channel: channel, + peakDb: peakDb, + rmsDb: rmsDb, + dynamicRangeDb: peakDb != null && rmsDb != null ? peakDb - rmsDb : null, + peakCount: + _parseLastAstatsInt(section, 'Peak count') ?? + _parseLastAstatsInt(section, 'Peak count ch') ?? + 0, + ), + ); + } + + return stats; +} + +double? _parseLastAstatsValue(String text, String label) { + return _parseLastAudioAstatsValue(text, label); +} + +int? _parseLastAstatsInt(String text, String label) { + final matches = RegExp( + '${RegExp.escape(label)}:\\s*(\\d+)', + caseSensitive: false, + ).allMatches(text); + int? value; + for (final match in matches) { + value = int.tryParse(match.group(1) ?? '') ?? value; + } + return value; +} + +String buildAudioMetricsFilter({ + required double durationSeconds, + required String metadataPath, +}) { + final metadataStart = math.max(0.0, durationSeconds - 2.0); + final escapedPath = metadataPath + .replaceAll(r'\', r'\\') + .replaceAll(':', r'\:') + .replaceAll("'", r"\'"); + return 'astats=metadata=1:reset=0:' + 'measure_perchannel=Peak_level+RMS_level+Peak_count:' + 'measure_overall=Peak_level+RMS_level,' + 'ebur128=peak=true:metadata=1:framelog=quiet,' + "aselect='gte(t,${metadataStart.toStringAsFixed(3)})'," + "ametadata=print:file='$escapedPath'"; +} + +List buildAudioMetricsArguments({ + required String inputPath, + required String metadataPath, + required double durationSeconds, +}) { + // FFmpegKit's log level is process-global. A native download finalizer can + // run `-v error` concurrently, so analyzer results must come from this + // session's metadata file rather than info-level log callbacks. + return [ + '-hide_banner', + '-nostats', + '-i', + inputPath, + '-map', + '0:a:0', + '-vn', + '-sn', + '-dn', + '-af', + buildAudioMetricsFilter( + durationSeconds: durationSeconds, + metadataPath: metadataPath, + ), + '-f', + 'null', + '-', + ]; +} + +AudioAnalysisMetadataSummary? parseAudioAnalysisMetadata(String metadata) { + final peak = _parseLastMetadataValue( + metadata, + 'lavfi.astats.Overall.Peak_level', + ); + final rms = _parseLastMetadataValue( + metadata, + 'lavfi.astats.Overall.RMS_level', + ); + if (peak == null || rms == null) return null; + + final channelNumbers = RegExp( + r'^lavfi\.astats\.(\d+)\.', + multiLine: true, + ).allMatches(metadata).map((match) => int.parse(match.group(1)!)).toSet(); + final channelStats = channelNumbers.toList()..sort(); + + final truePeakLinear = _parseLastMetadataValue( + metadata, + 'lavfi.r128.true_peak', + ); + return AudioAnalysisMetadataSummary( + peakDb: peak, + rmsDb: rms, + integratedLufs: _parseLastMetadataValue(metadata, 'lavfi.r128.I'), + truePeakDb: truePeakLinear != null && truePeakLinear > 0 + ? 20 * math.log(truePeakLinear) / math.ln10 + : null, + channelStats: channelStats.map((channel) { + final channelPeak = _parseLastMetadataValue( + metadata, + 'lavfi.astats.$channel.Peak_level', + ); + final channelRms = _parseLastMetadataValue( + metadata, + 'lavfi.astats.$channel.RMS_level', + ); + return ChannelAnalysisStats( + channel: channel, + peakDb: channelPeak, + rmsDb: channelRms, + dynamicRangeDb: channelPeak != null && channelRms != null + ? channelPeak - channelRms + : null, + peakCount: + _parseLastMetadataValue( + metadata, + 'lavfi.astats.$channel.Peak_count', + )?.round() ?? + 0, + ); + }).toList(), + ); +} + +double? _parseLastMetadataValue(String metadata, String key) { + final matches = RegExp( + '^${RegExp.escape(key)}=([^\\r\\n]+)', + multiLine: true, + ).allMatches(metadata); + double? value; + for (final match in matches) { + final parsed = double.tryParse(match.group(1)?.trim() ?? ''); + if (parsed != null && parsed.isFinite) value = parsed; + } + return value; +} + +double? estimateEffectiveSpectralCutoffHz({ + required Uint8List intensity, + required int width, + required int height, + required double maxFrequencyHz, +}) { + if (width <= 0 || + height <= 0 || + maxFrequencyHz <= 0 || + intensity.length < width * height) { + return null; + } + + // Use a high temporal percentile so sustained musical bandwidth wins over + // silence, while short ultrasonic transients do not define the cutoff. + // These bytes come from FFmpeg's fixed-hue `color=green` output, where gray + // value is monotonic with magnitude; display-palette RGB is deliberately not + // accepted here because its blue noise floor has deceptively large channels. + final profile = Float64List(height); + final histogram = Uint32List(256); + final percentileTarget = math.max(0, (width * 0.90).floor()); + for (var y = 0; y < height; y++) { + histogram.fillRange(0, histogram.length, 0); + final rowStart = y * width; + for (var x = 0; x < width; x++) { + histogram[intensity[rowStart + x]]++; + } + + var cumulative = 0; + for (var value = 0; value < histogram.length; value++) { + cumulative += histogram[value]; + if (cumulative > percentileTarget) { + // showspectrumpic stores Nyquist at the top. Reverse it here so array + // indices increase with frequency, which makes edge detection clearer. + profile[height - y - 1] = value.toDouble(); + break; + } + } + } + + 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; + var windowStart = 0; + var windowEnd = -1; + for (var index = 0; index < height; index++) { + final desiredStart = math.max(0, index - smoothingRadius); + final desiredEnd = math.min(height - 1, index + smoothingRadius); + while (windowEnd < desiredEnd) { + windowEnd++; + running += broadbandProfile[windowEnd]; + } + while (windowStart < desiredStart) { + running -= broadbandProfile[windowStart]; + windowStart++; + } + smoothed[index] = running / (windowEnd - windowStart + 1); + } + + final centralStart = math.max(0, (height * 0.05).floor()); + final centralEnd = math.min(height, (height * 0.95).ceil()); + final central = _sortedSpectralWindow(smoothed, centralStart, centralEnd); + final lowLevel = _sortedSpectralPercentile(central, 0.10); + final highLevel = _sortedSpectralPercentile(central, 0.95); + final dynamicSpan = highLevel - lowLevel; + if (highLevel < 24) return null; + if (dynamicSpan < 1) return maxFrequencyHz; + + // Locate a sharp downward edge over roughly 200 Hz, then validate it using + // wider bands on both sides. This detects codec/low-pass bandwidth edges but + // rejects a gradual musical roll-off or a narrow ultrasonic pilot. + final edgeSpanRows = math.max(2, (200 / hzPerRow).ceil()); + final topGuardRows = math.max(edgeSpanRows, (500 / hzPerRow).ceil()); + final minSearchHz = math.max(1000.0, math.min(4000.0, maxFrequencyHz * 0.20)); + final searchStart = math.max(edgeSpanRows, (minSearchHz / hzPerRow).floor()); + final searchEnd = height - edgeSpanRows - topGuardRows; + final candidateStarts = []; + for (var start = searchStart; start < searchEnd; start++) { + final drop = smoothed[start] - smoothed[start + edgeSpanRows]; + if (drop > 0) candidateStarts.add(start); + } + candidateStarts.sort((a, b) { + final aDrop = smoothed[a] - smoothed[a + edgeSpanRows]; + final bDrop = smoothed[b] - smoothed[b + edgeSpanRows]; + return bDrop.compareTo(aDrop); + }); + + // 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); + 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 belowEnd = edgeIndex - gapRows; + final belowStart = math.max(0, belowEnd - supportRows); + final aboveStart = edgeIndex + gapRows; + final aboveEnd = math.min(height, aboveStart + supportRows); + if (belowEnd > belowStart && aboveEnd > aboveStart) { + final belowLevel = _spectralMedian(smoothed, belowStart, belowEnd); + final aboveLevel = _spectralMedian(smoothed, aboveStart, aboveEnd); + if (belowLevel - aboveLevel < minimumDrop) continue; + // All tail statistics share one sorted window. Rejected local edges do + // not need to copy and sort the rest of the spectrum at all. + final tail = _sortedSpectralWindow(smoothed, aboveStart, height); + final tailLevel = _sortedSpectralPercentile(tail, 0.50); + if (belowLevel - tailLevel < minimumDrop) continue; + final tailSpread = + _sortedSpectralPercentile(tail, 0.80) - + _sortedSpectralPercentile(tail, 0.20); + if (tailSpread > stableTailSpread) continue; + final baseStart = math.max(0, edgeIndex - (3000 / hzPerRow).ceil()); + final baseLevel = _spectralMedian(smoothed, baseStart, belowEnd); + if (baseLevel - tailLevel >= minimumDrop) { + final cutoff = (edgeIndex + 0.5) * hzPerRow; + return cutoff.clamp(0.0, maxFrequencyHz).toDouble(); + } + } + } + + // 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()), + ); + // 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; + 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 tail = _sortedSpectralWindow(smoothed, aboveStart, height); + final tailLevel = _sortedSpectralPercentile(tail, 0.50); + if (belowLevel - tailLevel < activeMargin) continue; + final tailSpread = + _sortedSpectralPercentile(tail, 0.80) - + _sortedSpectralPercentile(tail, 0.20); + if (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(), + math.max(1, (height * 0.50).floor()), + ); + final topBandLevel = _spectralMedian( + smoothed, + (height * 0.90).floor(), + math.max(1, (height * 0.98).floor()), + ); + final lowerTopBandLevel = _spectralMedian( + smoothed, + (height * 0.80).floor(), + 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 = + upperBandSlope >= math.max(1.0, dynamicSpan * 0.05); + if (basebandLevel >= 24 && + topBandLevel >= 24 && + (topBandNearPeak || topBandStillSloping)) { + return maxFrequencyHz; + } + return null; +} + +double _spectralMedian(Float64List values, int start, int end) { + return _sortedSpectralPercentile( + _sortedSpectralWindow(values, start, end), + 0.50, + ); +} + +Float64List _sortedSpectralWindow(Float64List values, int start, int end) { + final safeStart = start.clamp(0, values.length).toInt(); + final safeEnd = end.clamp(safeStart, values.length).toInt(); + return values.sublist(safeStart, safeEnd)..sort(); +} + +double _sortedSpectralPercentile(Float64List sorted, double percentile) { + if (sorted.isEmpty) return 0; + final index = ((sorted.length - 1) * percentile) + .round() + .clamp(0, sorted.length - 1) + .toInt(); + return sorted[index]; +} diff --git a/lib/services/audio_analysis_models.dart b/lib/services/audio_analysis_models.dart new file mode 100644 index 00000000..139b35e0 --- /dev/null +++ b/lib/services/audio_analysis_models.dart @@ -0,0 +1,141 @@ +// Analysis result models. `AudioAnalysisData` is the persisted cache JSON. + +class AudioAnalysisData { + static const cacheVersion = 13; + + final String filePath; + final int fileSize; + final String codec; + final String container; + final String decodedSampleFormat; + final int sampleRate; + final int channels; + final String channelLayout; + final int bitsPerSample; + final double duration; + final int bitrate; + final String bitDepth; + final double dynamicRange; + final double peakAmplitude; + final double rmsLevel; + final double? integratedLufs; + final double? truePeakDb; + final int clippingSamples; + final double? spectralCutoffHz; + final List channelStats; + final int totalSamples; + + const AudioAnalysisData({ + required this.filePath, + required this.fileSize, + this.codec = '', + this.container = '', + this.decodedSampleFormat = '', + required this.sampleRate, + required this.channels, + this.channelLayout = '', + required this.bitsPerSample, + required this.duration, + required this.bitrate, + required this.bitDepth, + required this.dynamicRange, + required this.peakAmplitude, + required this.rmsLevel, + this.integratedLufs, + this.truePeakDb, + this.clippingSamples = 0, + this.spectralCutoffHz, + this.channelStats = const [], + required this.totalSamples, + }); + + Map toJson() => { + 'filePath': filePath, + 'cacheVersion': cacheVersion, + 'fileSize': fileSize, + 'codec': codec, + 'container': container, + 'decodedSampleFormat': decodedSampleFormat, + 'sampleRate': sampleRate, + 'channels': channels, + 'channelLayout': channelLayout, + 'bitsPerSample': bitsPerSample, + 'duration': duration, + 'bitrate': bitrate, + 'bitDepth': bitDepth, + 'dynamicRange': dynamicRange, + 'peakAmplitude': peakAmplitude, + 'rmsLevel': rmsLevel, + 'integratedLufs': integratedLufs, + 'truePeakDb': truePeakDb, + 'clippingSamples': clippingSamples, + 'spectralCutoffHz': spectralCutoffHz, + 'channelStats': channelStats.map((stats) => stats.toJson()).toList(), + 'totalSamples': totalSamples, + }; + + factory AudioAnalysisData.fromJson(Map json) { + return AudioAnalysisData( + filePath: json['filePath'] as String, + fileSize: json['fileSize'] as int, + codec: json['codec']?.toString() ?? '', + container: json['container']?.toString() ?? '', + decodedSampleFormat: json['decodedSampleFormat']?.toString() ?? '', + sampleRate: json['sampleRate'] as int, + channels: json['channels'] as int, + channelLayout: json['channelLayout']?.toString() ?? '', + bitsPerSample: json['bitsPerSample'] as int, + duration: (json['duration'] as num).toDouble(), + bitrate: json['bitrate'] as int, + bitDepth: json['bitDepth'] as String, + dynamicRange: (json['dynamicRange'] as num).toDouble(), + peakAmplitude: (json['peakAmplitude'] as num).toDouble(), + rmsLevel: (json['rmsLevel'] as num).toDouble(), + integratedLufs: (json['integratedLufs'] as num?)?.toDouble(), + truePeakDb: (json['truePeakDb'] as num?)?.toDouble(), + clippingSamples: (json['clippingSamples'] as num?)?.toInt() ?? 0, + spectralCutoffHz: (json['spectralCutoffHz'] as num?)?.toDouble(), + channelStats: + (json['channelStats'] as List?) + ?.whereType>() + .map((item) => ChannelAnalysisStats.fromJson(item)) + .toList() ?? + const [], + totalSamples: json['totalSamples'] as int, + ); + } +} + +class ChannelAnalysisStats { + final int channel; + final double? peakDb; + final double? rmsDb; + final double? dynamicRangeDb; + final int peakCount; + + const ChannelAnalysisStats({ + required this.channel, + this.peakDb, + this.rmsDb, + this.dynamicRangeDb, + this.peakCount = 0, + }); + + Map toJson() => { + 'channel': channel, + 'peakDb': peakDb, + 'rmsDb': rmsDb, + 'dynamicRangeDb': dynamicRangeDb, + 'peakCount': peakCount, + }; + + factory ChannelAnalysisStats.fromJson(Map json) { + return ChannelAnalysisStats( + channel: (json['channel'] as num?)?.toInt() ?? 0, + peakDb: (json['peakDb'] as num?)?.toDouble(), + rmsDb: (json['rmsDb'] as num?)?.toDouble(), + dynamicRangeDb: (json['dynamicRangeDb'] as num?)?.toDouble(), + peakCount: (json['peakCount'] as num?)?.toInt() ?? 0, + ); + } +} diff --git a/lib/widgets/audio_analysis_models.dart b/lib/widgets/audio_analysis_models.dart index 786d9b09..ac8fe1c1 100644 --- a/lib/widgets/audio_analysis_models.dart +++ b/lib/widgets/audio_analysis_models.dart @@ -1,146 +1,7 @@ part of 'audio_analysis_widget.dart'; -// Analysis result models and per-run parameter records. - -class AudioAnalysisData { - static const cacheVersion = 13; - - final String filePath; - final int fileSize; - final String codec; - final String container; - final String decodedSampleFormat; - final int sampleRate; - final int channels; - final String channelLayout; - final int bitsPerSample; - final double duration; - final int bitrate; - final String bitDepth; - final double dynamicRange; - final double peakAmplitude; - final double rmsLevel; - final double? integratedLufs; - final double? truePeakDb; - final int clippingSamples; - final double? spectralCutoffHz; - final List channelStats; - final int totalSamples; - - const AudioAnalysisData({ - required this.filePath, - required this.fileSize, - this.codec = '', - this.container = '', - this.decodedSampleFormat = '', - required this.sampleRate, - required this.channels, - this.channelLayout = '', - required this.bitsPerSample, - required this.duration, - required this.bitrate, - required this.bitDepth, - required this.dynamicRange, - required this.peakAmplitude, - required this.rmsLevel, - this.integratedLufs, - this.truePeakDb, - this.clippingSamples = 0, - this.spectralCutoffHz, - this.channelStats = const [], - required this.totalSamples, - }); - - Map toJson() => { - 'filePath': filePath, - 'cacheVersion': cacheVersion, - 'fileSize': fileSize, - 'codec': codec, - 'container': container, - 'decodedSampleFormat': decodedSampleFormat, - 'sampleRate': sampleRate, - 'channels': channels, - 'channelLayout': channelLayout, - 'bitsPerSample': bitsPerSample, - 'duration': duration, - 'bitrate': bitrate, - 'bitDepth': bitDepth, - 'dynamicRange': dynamicRange, - 'peakAmplitude': peakAmplitude, - 'rmsLevel': rmsLevel, - 'integratedLufs': integratedLufs, - 'truePeakDb': truePeakDb, - 'clippingSamples': clippingSamples, - 'spectralCutoffHz': spectralCutoffHz, - 'channelStats': channelStats.map((stats) => stats.toJson()).toList(), - 'totalSamples': totalSamples, - }; - - factory AudioAnalysisData.fromJson(Map json) { - return AudioAnalysisData( - filePath: json['filePath'] as String, - fileSize: json['fileSize'] as int, - codec: json['codec']?.toString() ?? '', - container: json['container']?.toString() ?? '', - decodedSampleFormat: json['decodedSampleFormat']?.toString() ?? '', - sampleRate: json['sampleRate'] as int, - channels: json['channels'] as int, - channelLayout: json['channelLayout']?.toString() ?? '', - bitsPerSample: json['bitsPerSample'] as int, - duration: (json['duration'] as num).toDouble(), - bitrate: json['bitrate'] as int, - bitDepth: json['bitDepth'] as String, - dynamicRange: (json['dynamicRange'] as num).toDouble(), - peakAmplitude: (json['peakAmplitude'] as num).toDouble(), - rmsLevel: (json['rmsLevel'] as num).toDouble(), - integratedLufs: (json['integratedLufs'] as num?)?.toDouble(), - truePeakDb: (json['truePeakDb'] as num?)?.toDouble(), - clippingSamples: (json['clippingSamples'] as num?)?.toInt() ?? 0, - spectralCutoffHz: (json['spectralCutoffHz'] as num?)?.toDouble(), - channelStats: - (json['channelStats'] as List?) - ?.whereType>() - .map((item) => ChannelAnalysisStats.fromJson(item)) - .toList() ?? - const [], - totalSamples: json['totalSamples'] as int, - ); - } -} - -class ChannelAnalysisStats { - final int channel; - final double? peakDb; - final double? rmsDb; - final double? dynamicRangeDb; - final int peakCount; - - const ChannelAnalysisStats({ - required this.channel, - this.peakDb, - this.rmsDb, - this.dynamicRangeDb, - this.peakCount = 0, - }); - - Map toJson() => { - 'channel': channel, - 'peakDb': peakDb, - 'rmsDb': rmsDb, - 'dynamicRangeDb': dynamicRangeDb, - 'peakCount': peakCount, - }; - - factory ChannelAnalysisStats.fromJson(Map json) { - return ChannelAnalysisStats( - channel: (json['channel'] as num?)?.toInt() ?? 0, - peakDb: (json['peakDb'] as num?)?.toDouble(), - rmsDb: (json['rmsDb'] as num?)?.toDouble(), - dynamicRangeDb: (json['dynamicRangeDb'] as num?)?.toDouble(), - peakCount: (json['peakCount'] as num?)?.toInt() ?? 0, - ); - } -} +// Per-run parameter records. The public result models live in +// services/audio_analysis_models.dart. class _GeneratedSpectrogram { final ui.Image image; diff --git a/lib/widgets/audio_analysis_widget.dart b/lib/widgets/audio_analysis_widget.dart index 15834125..9271156f 100644 --- a/lib/widgets/audio_analysis_widget.dart +++ b/lib/widgets/audio_analysis_widget.dart @@ -1,8 +1,6 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; -import 'dart:math' as math; -import 'dart:typed_data'; import 'dart:ui' as ui; import 'package:ffmpeg_kit_flutter_new_audio/ffmpeg_kit.dart'; import 'package:ffmpeg_kit_flutter_new_audio/ffmpeg_session.dart'; @@ -14,6 +12,8 @@ import 'package:flutter/services.dart'; import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/services/audio_analysis_jobs.dart'; import 'package:spotiflac_android/services/audio_analysis_cache.dart'; +import 'package:spotiflac_android/services/audio_analysis_ffmpeg.dart'; +import 'package:spotiflac_android/services/audio_analysis_models.dart'; import 'package:spotiflac_android/services/network_audio_analysis_input.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/widgets/app_content_card.dart'; @@ -26,573 +26,13 @@ import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/string_utils.dart'; +export 'package:spotiflac_android/services/audio_analysis_ffmpeg.dart'; +export 'package:spotiflac_android/services/audio_analysis_models.dart'; + part 'audio_analysis_models.dart'; part 'audio_analysis_info_card.dart'; part 'audio_analysis_spectrogram.dart'; -const int audioSpectrogramWidth = 1600; -const int audioSpectrogramHeight = 800; -const int audioSpectralAnalysisWidth = 400; -const double audioSpectrogramDynamicRangeDb = 120; -// 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( - double? cutoffHz, { - required String notDetectedLabel, -}) { - if (cutoffHz == null || !cutoffHz.isFinite || cutoffHz <= 0) { - return notDetectedLabel; - } - if (cutoffHz >= 1000) { - return '${(cutoffHz / 1000).toStringAsFixed(1)} kHz'; - } - return '${cutoffHz.round()} Hz'; -} - -final RegExp _ac4CodecTokenPattern = RegExp( - r'(^|[^a-z0-9])ac[\s_-]?4($|[^a-z0-9])', - caseSensitive: false, -); - -/// Returns whether the bundled FFmpeg decoder can analyze this audio codec. -/// Container formats such as MP4 are intentionally not used here because the -/// same container may hold supported AAC/ALAC/E-AC-3 or unsupported AC-4. -bool isAudioAnalysisCodecSupported(String? codecName) { - final value = codecName?.trim() ?? ''; - if (value.isEmpty) return true; - return !_ac4CodecTokenPattern.hasMatch(value); -} - -String? unsupportedAudioAnalysisCodecLabel(String? codecName) => - isAudioAnalysisCodecSupported(codecName) ? null : 'AC-4'; - -class _UnsupportedAudioAnalysisCodecException implements Exception { - final String codecLabel; - - const _UnsupportedAudioAnalysisCodecException(this.codecLabel); -} - -String _buildShowspectrumOptions({required int width, required String color}) { - return 'showspectrumpic=' - 's=${width}x$audioSpectrogramHeight:' - 'legend=0:mode=combined:color=$color:scale=log:fscale=lin:' - 'win_func=hann:drange=${audioSpectrogramDynamicRangeDb.toStringAsFixed(0)}:' - 'limit=0'; -} - -String buildAudioSpectrogramFilter({ - int channel = -1, - bool includeCutoffPlane = false, - double? durationSeconds, - int? sampleRate, - int? channels, -}) { - final channelFilter = channel >= 0 ? 'pan=mono|c0=c$channel,' : ''; - 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', - ); - if (!includeCutoffPlane) { - return '$input,$display,format=rgba[spectrum]'; - } - - // The display palette encodes quiet bins as saturated blue/purple pixels, - // so RGB brightness is not a monotonic measure of spectral magnitude. Keep - // the colorful UI output, but generate a small fixed-hue plane whose gray - // values can be used safely for effective-bandwidth detection. - final cutoff = _buildShowspectrumOptions( - width: audioSpectralAnalysisWidth, - color: 'green', - ); - return '$input,asplit=2[display_input][cutoff_input];' - '[display_input]$display,format=rgba[spectrum];' - '[cutoff_input]$cutoff,format=gray[cutoff]'; -} - -List buildAudioSpectrogramArguments({ - required String inputPath, - required String outputPath, - String? cutoffOutputPath, - int channel = -1, - double? durationSeconds, - int? sampleRate, - int? channels, -}) { - final arguments = [ - '-hide_banner', - '-y', - '-i', - inputPath, - '-filter_complex', - buildAudioSpectrogramFilter( - channel: channel, - includeCutoffPlane: cutoffOutputPath != null, - durationSeconds: durationSeconds, - sampleRate: sampleRate, - channels: channels, - ), - '-map', - '[spectrum]', - '-frames:v', - '1', - '-f', - 'rawvideo', - '-pix_fmt', - 'rgba', - outputPath, - ]; - if (cutoffOutputPath != null) { - arguments.addAll([ - '-map', - '[cutoff]', - '-frames:v', - '1', - '-f', - 'rawvideo', - '-pix_fmt', - 'gray', - cutoffOutputPath, - ]); - } - 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; - - const AudioAstatsSummary({required this.peakDb, required this.rmsDb}); -} - -class AudioAnalysisMetadataSummary extends AudioAstatsSummary { - final double? integratedLufs; - final double? truePeakDb; - final List channelStats; - - const AudioAnalysisMetadataSummary({ - required super.peakDb, - required super.rmsDb, - this.integratedLufs, - this.truePeakDb, - this.channelStats = const [], - }); -} - -AudioAstatsSummary? parseAudioAstatsSummary(String logs) { - final overallMatch = RegExp(r'Overall([\s\S]*)').firstMatch(logs); - final section = overallMatch?.group(1) ?? logs; - final peak = _parseLastAudioAstatsValue(section, 'Peak level dB'); - final rms = _parseLastAudioAstatsValue(section, 'RMS level dB'); - if (peak == null || rms == null) return null; - return AudioAstatsSummary(peakDb: peak, rmsDb: rms); -} - -double? _parseLastAudioAstatsValue(String text, String label) { - final matches = RegExp( - '${RegExp.escape(label)}:\\s*([-+]?\\d+(?:\\.\\d+)?)', - caseSensitive: false, - ).allMatches(text); - double? value; - for (final match in matches) { - final parsed = double.tryParse(match.group(1) ?? ''); - if (parsed != null && parsed.isFinite) { - value = parsed; - } - } - return value; -} - -String buildAudioMetricsFilter({ - required double durationSeconds, - required String metadataPath, -}) { - final metadataStart = math.max(0.0, durationSeconds - 2.0); - final escapedPath = metadataPath - .replaceAll(r'\', r'\\') - .replaceAll(':', r'\:') - .replaceAll("'", r"\'"); - return 'astats=metadata=1:reset=0:' - 'measure_perchannel=Peak_level+RMS_level+Peak_count:' - 'measure_overall=Peak_level+RMS_level,' - 'ebur128=peak=true:metadata=1:framelog=quiet,' - "aselect='gte(t,${metadataStart.toStringAsFixed(3)})'," - "ametadata=print:file='$escapedPath'"; -} - -List buildAudioMetricsArguments({ - required String inputPath, - required String metadataPath, - required double durationSeconds, -}) { - // FFmpegKit's log level is process-global. A native download finalizer can - // run `-v error` concurrently, so analyzer results must come from this - // session's metadata file rather than info-level log callbacks. - return [ - '-hide_banner', - '-nostats', - '-i', - inputPath, - '-map', - '0:a:0', - '-vn', - '-sn', - '-dn', - '-af', - buildAudioMetricsFilter( - durationSeconds: durationSeconds, - metadataPath: metadataPath, - ), - '-f', - 'null', - '-', - ]; -} - -AudioAnalysisMetadataSummary? parseAudioAnalysisMetadata(String metadata) { - final peak = _parseLastMetadataValue( - metadata, - 'lavfi.astats.Overall.Peak_level', - ); - final rms = _parseLastMetadataValue( - metadata, - 'lavfi.astats.Overall.RMS_level', - ); - if (peak == null || rms == null) return null; - - final channelNumbers = RegExp( - r'^lavfi\.astats\.(\d+)\.', - multiLine: true, - ).allMatches(metadata).map((match) => int.parse(match.group(1)!)).toSet(); - final channelStats = channelNumbers.toList()..sort(); - - final truePeakLinear = _parseLastMetadataValue( - metadata, - 'lavfi.r128.true_peak', - ); - return AudioAnalysisMetadataSummary( - peakDb: peak, - rmsDb: rms, - integratedLufs: _parseLastMetadataValue(metadata, 'lavfi.r128.I'), - truePeakDb: truePeakLinear != null && truePeakLinear > 0 - ? 20 * math.log(truePeakLinear) / math.ln10 - : null, - channelStats: channelStats.map((channel) { - final channelPeak = _parseLastMetadataValue( - metadata, - 'lavfi.astats.$channel.Peak_level', - ); - final channelRms = _parseLastMetadataValue( - metadata, - 'lavfi.astats.$channel.RMS_level', - ); - return ChannelAnalysisStats( - channel: channel, - peakDb: channelPeak, - rmsDb: channelRms, - dynamicRangeDb: channelPeak != null && channelRms != null - ? channelPeak - channelRms - : null, - peakCount: - _parseLastMetadataValue( - metadata, - 'lavfi.astats.$channel.Peak_count', - )?.round() ?? - 0, - ); - }).toList(), - ); -} - -double? _parseLastMetadataValue(String metadata, String key) { - final matches = RegExp( - '^${RegExp.escape(key)}=([^\\r\\n]+)', - multiLine: true, - ).allMatches(metadata); - double? value; - for (final match in matches) { - final parsed = double.tryParse(match.group(1)?.trim() ?? ''); - if (parsed != null && parsed.isFinite) value = parsed; - } - return value; -} - -double? estimateEffectiveSpectralCutoffHz({ - required Uint8List intensity, - required int width, - required int height, - required double maxFrequencyHz, -}) { - if (width <= 0 || - height <= 0 || - maxFrequencyHz <= 0 || - intensity.length < width * height) { - return null; - } - - // Use a high temporal percentile so sustained musical bandwidth wins over - // silence, while short ultrasonic transients do not define the cutoff. - // These bytes come from FFmpeg's fixed-hue `color=green` output, where gray - // value is monotonic with magnitude; display-palette RGB is deliberately not - // accepted here because its blue noise floor has deceptively large channels. - final profile = Float64List(height); - final histogram = Uint32List(256); - final percentileTarget = math.max(0, (width * 0.90).floor()); - for (var y = 0; y < height; y++) { - histogram.fillRange(0, histogram.length, 0); - final rowStart = y * width; - for (var x = 0; x < width; x++) { - histogram[intensity[rowStart + x]]++; - } - - var cumulative = 0; - for (var value = 0; value < histogram.length; value++) { - cumulative += histogram[value]; - if (cumulative > percentileTarget) { - // showspectrumpic stores Nyquist at the top. Reverse it here so array - // indices increase with frequency, which makes edge detection clearer. - profile[height - y - 1] = value.toDouble(); - break; - } - } - } - - 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; - var windowStart = 0; - var windowEnd = -1; - for (var index = 0; index < height; index++) { - final desiredStart = math.max(0, index - smoothingRadius); - final desiredEnd = math.min(height - 1, index + smoothingRadius); - while (windowEnd < desiredEnd) { - windowEnd++; - running += broadbandProfile[windowEnd]; - } - while (windowStart < desiredStart) { - running -= broadbandProfile[windowStart]; - windowStart++; - } - smoothed[index] = running / (windowEnd - windowStart + 1); - } - - final centralStart = math.max(0, (height * 0.05).floor()); - final centralEnd = math.min(height, (height * 0.95).ceil()); - final central = _sortedSpectralWindow(smoothed, centralStart, centralEnd); - final lowLevel = _sortedSpectralPercentile(central, 0.10); - final highLevel = _sortedSpectralPercentile(central, 0.95); - final dynamicSpan = highLevel - lowLevel; - if (highLevel < 24) return null; - if (dynamicSpan < 1) return maxFrequencyHz; - - // Locate a sharp downward edge over roughly 200 Hz, then validate it using - // wider bands on both sides. This detects codec/low-pass bandwidth edges but - // rejects a gradual musical roll-off or a narrow ultrasonic pilot. - final edgeSpanRows = math.max(2, (200 / hzPerRow).ceil()); - final topGuardRows = math.max(edgeSpanRows, (500 / hzPerRow).ceil()); - final minSearchHz = math.max(1000.0, math.min(4000.0, maxFrequencyHz * 0.20)); - final searchStart = math.max(edgeSpanRows, (minSearchHz / hzPerRow).floor()); - final searchEnd = height - edgeSpanRows - topGuardRows; - final candidateStarts = []; - for (var start = searchStart; start < searchEnd; start++) { - final drop = smoothed[start] - smoothed[start + edgeSpanRows]; - if (drop > 0) candidateStarts.add(start); - } - candidateStarts.sort((a, b) { - final aDrop = smoothed[a] - smoothed[a + edgeSpanRows]; - final bDrop = smoothed[b] - smoothed[b + edgeSpanRows]; - return bDrop.compareTo(aDrop); - }); - - // 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); - 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 belowEnd = edgeIndex - gapRows; - final belowStart = math.max(0, belowEnd - supportRows); - final aboveStart = edgeIndex + gapRows; - final aboveEnd = math.min(height, aboveStart + supportRows); - if (belowEnd > belowStart && aboveEnd > aboveStart) { - final belowLevel = _spectralMedian(smoothed, belowStart, belowEnd); - final aboveLevel = _spectralMedian(smoothed, aboveStart, aboveEnd); - if (belowLevel - aboveLevel < minimumDrop) continue; - // All tail statistics share one sorted window. Rejected local edges do - // not need to copy and sort the rest of the spectrum at all. - final tail = _sortedSpectralWindow(smoothed, aboveStart, height); - final tailLevel = _sortedSpectralPercentile(tail, 0.50); - if (belowLevel - tailLevel < minimumDrop) continue; - final tailSpread = - _sortedSpectralPercentile(tail, 0.80) - - _sortedSpectralPercentile(tail, 0.20); - if (tailSpread > stableTailSpread) continue; - final baseStart = math.max(0, edgeIndex - (3000 / hzPerRow).ceil()); - final baseLevel = _spectralMedian(smoothed, baseStart, belowEnd); - if (baseLevel - tailLevel >= minimumDrop) { - final cutoff = (edgeIndex + 0.5) * hzPerRow; - return cutoff.clamp(0.0, maxFrequencyHz).toDouble(); - } - } - } - - // 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()), - ); - // 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; - 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 tail = _sortedSpectralWindow(smoothed, aboveStart, height); - final tailLevel = _sortedSpectralPercentile(tail, 0.50); - if (belowLevel - tailLevel < activeMargin) continue; - final tailSpread = - _sortedSpectralPercentile(tail, 0.80) - - _sortedSpectralPercentile(tail, 0.20); - if (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(), - math.max(1, (height * 0.50).floor()), - ); - final topBandLevel = _spectralMedian( - smoothed, - (height * 0.90).floor(), - math.max(1, (height * 0.98).floor()), - ); - final lowerTopBandLevel = _spectralMedian( - smoothed, - (height * 0.80).floor(), - 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 = - upperBandSlope >= math.max(1.0, dynamicSpan * 0.05); - if (basebandLevel >= 24 && - topBandLevel >= 24 && - (topBandNearPeak || topBandStillSloping)) { - return maxFrequencyHz; - } - return null; -} - -double _spectralMedian(Float64List values, int start, int end) { - return _sortedSpectralPercentile( - _sortedSpectralWindow(values, start, end), - 0.50, - ); -} - -Float64List _sortedSpectralWindow(Float64List values, int start, int end) { - final safeStart = start.clamp(0, values.length).toInt(); - final safeEnd = end.clamp(safeStart, values.length).toInt(); - return values.sublist(safeStart, safeEnd)..sort(); -} - -double _sortedSpectralPercentile(Float64List sorted, double percentile) { - if (sorted.isEmpty) return 0; - final index = ((sorted.length - 1) * percentile) - .round() - .clamp(0, sorted.length - 1) - .toInt(); - return sorted[index]; -} - class AudioAnalysisCard extends StatefulWidget { final String filePath; final String? codecHint; @@ -844,7 +284,7 @@ class _AudioAnalysisCardState extends State { if (cached != null) { final unsupported = unsupportedAudioAnalysisCodecLabel(cached.codec); if (unsupported != null) { - throw _UnsupportedAudioAnalysisCodecException(unsupported); + throw UnsupportedAudioAnalysisCodecException(unsupported); } data = cached; image = await _loadSpectrogramFromCache( @@ -881,7 +321,7 @@ class _AudioAnalysisCardState extends State { } } on AudioAnalysisCancelled { // A new file/request owns the state; cancelled native work is cleaned up. - } on _UnsupportedAudioAnalysisCodecException catch (e) { + } on UnsupportedAudioAnalysisCodecException catch (e) { if (mounted && requestId == _spectrogramRequestId) { setState(() { _unsupportedCodec = e.codecLabel; @@ -1068,7 +508,7 @@ class _AudioAnalysisCardState extends State { _checkAnalysisRequest(requestId); final unsupported = unsupportedAudioAnalysisCodecLabel(info.codecName); if (unsupported != null) { - throw _UnsupportedAudioAnalysisCodecException(unsupported); + throw UnsupportedAudioAnalysisCodecException(unsupported); } _GeneratedSpectrogram? spectrogram; try { @@ -1557,7 +997,7 @@ class _AudioAnalysisCardState extends State { if (summary == null) return null; final channelStats = metadataSummary?.channelStats.isNotEmpty == true ? metadataSummary!.channelStats - : _parseChannelStats(logs); + : parseChannelStats(logs); final clippingSamples = channelStats.fold(0, (sum, stats) { if (stats.peakDb == null || stats.peakDb! < -0.1) return sum; return sum + stats.peakCount; @@ -1577,54 +1017,6 @@ class _AudioAnalysisCardState extends State { } } - List _parseChannelStats(String logs) { - final stats = []; - final channelMatches = RegExp( - r'Channel:\s*(\d+)([\s\S]*?)(?=Channel:\s*\d+|Overall|$)', - caseSensitive: false, - ).allMatches(logs); - - for (final match in channelMatches) { - final channel = int.tryParse(match.group(1) ?? '') ?? 0; - final section = match.group(2) ?? ''; - if (channel <= 0 || section.trim().isEmpty) continue; - final peakDb = _parseLastAstatsValue(section, 'Peak level dB'); - final rmsDb = _parseLastAstatsValue(section, 'RMS level dB'); - stats.add( - ChannelAnalysisStats( - channel: channel, - peakDb: peakDb, - rmsDb: rmsDb, - dynamicRangeDb: peakDb != null && rmsDb != null - ? peakDb - rmsDb - : null, - peakCount: - _parseLastAstatsInt(section, 'Peak count') ?? - _parseLastAstatsInt(section, 'Peak count ch') ?? - 0, - ), - ); - } - - return stats; - } - - double? _parseLastAstatsValue(String text, String label) { - return _parseLastAudioAstatsValue(text, label); - } - - int? _parseLastAstatsInt(String text, String label) { - final matches = RegExp( - '${RegExp.escape(label)}:\\s*(\\d+)', - caseSensitive: false, - ).allMatches(text); - int? value; - for (final match in matches) { - value = int.tryParse(match.group(1) ?? '') ?? value; - } - return value; - } - @override Widget build(BuildContext context) { if (!_isSupported) return const SizedBox.shrink();