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.
This commit is contained in:
zarzet committed 2026-10-19 00:59:24 +07:00
1 parent 7628800604
commit 34a004f96f
4 files changed
+768 -758

No files matched your search

+616
View File
@@ -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<String> buildAudioSpectrogramArguments({
required String inputPath,
required String outputPath,
String? cutoffOutputPath,
int channel = -1,
double? durationSeconds,
int? sampleRate,
int? channels,
}) {
final arguments = <String>[
'-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<ChannelAnalysisStats> 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<ChannelAnalysisStats> parseChannelStats(String logs) {
final stats = <ChannelAnalysisStats>[];
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<String> 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 = <int>[];
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];
}
+141
View File
@@ -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<ChannelAnalysisStats> 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<String, dynamic> 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<String, dynamic> 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<dynamic, dynamic>>()
.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<String, dynamic> toJson() => {
'channel': channel,
'peakDb': peakDb,
'rmsDb': rmsDb,
'dynamicRangeDb': dynamicRangeDb,
'peakCount': peakCount,
};
factory ChannelAnalysisStats.fromJson(Map<dynamic, dynamic> 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,
);
}
}
+2 -141
View File
@@ -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<ChannelAnalysisStats> 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<String, dynamic> 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<String, dynamic> 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<dynamic, dynamic>>()
.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<String, dynamic> toJson() => {
'channel': channel,
'peakDb': peakDb,
'rmsDb': rmsDb,
'dynamicRangeDb': dynamicRangeDb,
'peakCount': peakCount,
};
factory ChannelAnalysisStats.fromJson(Map<dynamic, dynamic> 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;
+9 -617
View File
@@ -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<String> buildAudioSpectrogramArguments({
required String inputPath,
required String outputPath,
String? cutoffOutputPath,
int channel = -1,
double? durationSeconds,
int? sampleRate,
int? channels,
}) {
final arguments = <String>[
'-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<ChannelAnalysisStats> 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<String> 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 = <int>[];
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<AudioAnalysisCard> {
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<AudioAnalysisCard> {
}
} 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<AudioAnalysisCard> {
_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<AudioAnalysisCard> {
if (summary == null) return null;
final channelStats = metadataSummary?.channelStats.isNotEmpty == true
? metadataSummary!.channelStats
: _parseChannelStats(logs);
: parseChannelStats(logs);
final clippingSamples = channelStats.fold<int>(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<AudioAnalysisCard> {
}
}
List<ChannelAnalysisStats> _parseChannelStats(String logs) {
final stats = <ChannelAnalysisStats>[];
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();