fix(analysis): isolate metrics from active downloads

This commit is contained in:
zarzet
2026-07-28 16:04:44 +07:00
parent 6c89a1e177
commit d1db2cdcc5
3 changed files with 219 additions and 89 deletions
+4 -7
View File
@@ -215,20 +215,17 @@ class _MediaInfo {
class _LevelMetrics { class _LevelMetrics {
final double peakDb; final double peakDb;
final double rmsDb; final double rmsDb;
final double? integratedLufs;
final double? truePeakDb;
final int clippingSamples; final int clippingSamples;
final List<ChannelAnalysisStats> channelStats; final List<ChannelAnalysisStats> channelStats;
const _LevelMetrics({ const _LevelMetrics({
required this.peakDb, required this.peakDb,
required this.rmsDb, required this.rmsDb,
this.integratedLufs,
this.truePeakDb,
this.clippingSamples = 0, this.clippingSamples = 0,
this.channelStats = const [], this.channelStats = const [],
}); });
} }
class _LoudnessMetrics {
final double? integratedLufs;
final double? truePeakDb;
const _LoudnessMetrics({this.integratedLufs, this.truePeakDb});
}
+169 -82
View File
@@ -5,9 +5,7 @@ import 'dart:math' as math;
import 'dart:typed_data'; import 'dart:typed_data';
import 'dart:ui' as ui; import 'dart:ui' as ui;
import 'package:ffmpeg_kit_flutter_new_full/ffmpeg_kit.dart'; import 'package:ffmpeg_kit_flutter_new_full/ffmpeg_kit.dart';
import 'package:ffmpeg_kit_flutter_new_full/ffmpeg_kit_config.dart';
import 'package:ffmpeg_kit_flutter_new_full/ffprobe_kit.dart'; import 'package:ffmpeg_kit_flutter_new_full/ffprobe_kit.dart';
import 'package:ffmpeg_kit_flutter_new_full/level.dart';
import 'package:ffmpeg_kit_flutter_new_full/return_code.dart'; import 'package:ffmpeg_kit_flutter_new_full/return_code.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -42,8 +40,6 @@ List<String> buildAudioSpectrogramArguments({
}) { }) {
return [ return [
'-hide_banner', '-hide_banner',
'-loglevel',
'error',
'-i', '-i',
inputPath, inputPath,
'-filter_complex', '-filter_complex',
@@ -68,6 +64,20 @@ class AudioAstatsSummary {
const AudioAstatsSummary({required this.peakDb, required this.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) { AudioAstatsSummary? parseAudioAstatsSummary(String logs) {
final overallMatch = RegExp(r'Overall([\s\S]*)').firstMatch(logs); final overallMatch = RegExp(r'Overall([\s\S]*)').firstMatch(logs);
final section = overallMatch?.group(1) ?? logs; final section = overallMatch?.group(1) ?? logs;
@@ -92,6 +102,120 @@ double? _parseLastAudioAstatsValue(String text, String label) {
return 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? estimateBroadbandSpectralCutoffHz({ double? estimateBroadbandSpectralCutoffHz({
required Uint8List rgba, required Uint8List rgba,
required int width, required int width,
@@ -470,8 +594,6 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
} }
Future<_AudioAnalysisRunResult> _runAnalysis(String filePath) async { Future<_AudioAnalysisRunResult> _runAnalysis(String filePath) async {
await FFmpegKitConfig.setLogLevel(Level.avLogError);
String workingPath = filePath; String workingPath = filePath;
String? tempCopy; String? tempCopy;
if (filePath.startsWith('content://')) { if (filePath.startsWith('content://')) {
@@ -496,11 +618,16 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
maxFrequencyHz: info.sampleRate / 2, maxFrequencyHz: info.sampleRate / 2,
), ),
); );
final levelMetrics = await _runFullStreamLevelAnalysis(workingPath); final effectiveDuration = info.totalSamples > 0 && info.sampleRate > 0
? info.totalSamples / info.sampleRate
: info.duration;
final levelMetrics = await _runFullStreamLevelAnalysis(
workingPath,
durationSeconds: effectiveDuration,
);
if (levelMetrics == null) { if (levelMetrics == null) {
throw Exception('FFmpeg level analysis returned no usable metrics'); throw Exception('FFmpeg level analysis returned no usable metrics');
} }
final loudnessMetrics = await _runLoudnessAnalysis(workingPath);
final peakAmplitude = levelMetrics.peakDb; final peakAmplitude = levelMetrics.peakDb;
final rmsLevel = levelMetrics.rmsDb; final rmsLevel = levelMetrics.rmsDb;
final dynamicRange = peakAmplitude - rmsLevel; final dynamicRange = peakAmplitude - rmsLevel;
@@ -524,8 +651,8 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
dynamicRange: dynamicRange, dynamicRange: dynamicRange,
peakAmplitude: peakAmplitude, peakAmplitude: peakAmplitude,
rmsLevel: rmsLevel, rmsLevel: rmsLevel,
integratedLufs: loudnessMetrics?.integratedLufs, integratedLufs: levelMetrics.integratedLufs,
truePeakDb: loudnessMetrics?.truePeakDb, truePeakDb: levelMetrics.truePeakDb,
clippingSamples: levelMetrics.clippingSamples, clippingSamples: levelMetrics.clippingSamples,
spectralCutoffHz: spectralCutoffHz, spectralCutoffHz: spectralCutoffHz,
channelStats: levelMetrics.channelStats, channelStats: levelMetrics.channelStats,
@@ -543,7 +670,6 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
await File(tempCopy).delete(); await File(tempCopy).delete();
} catch (_) {} } catch (_) {}
} }
await FFmpegKitConfig.setLogLevel(Level.avLogInfo);
} }
} }
@@ -835,39 +961,41 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
codecName.startsWith('pcm_'); codecName.startsWith('pcm_');
} }
Future<_LevelMetrics?> _runFullStreamLevelAnalysis(String inputPath) async { Future<_LevelMetrics?> _runFullStreamLevelAnalysis(
await FFmpegKitConfig.setLogLevel(Level.avLogInfo); String inputPath, {
required double durationSeconds,
}) async {
final tempDir = await getTemporaryDirectory();
final metadataFile = File(
'${tempDir.path}/analysis_metrics_'
'${DateTime.now().microsecondsSinceEpoch}.txt',
);
try { try {
final session = await FFmpegKit.executeWithArguments([ final session = await FFmpegKit.executeWithArguments(
'-v', buildAudioMetricsArguments(
'info', inputPath: inputPath,
'-hide_banner', metadataPath: metadataFile.path,
'-nostats', durationSeconds: durationSeconds,
'-i', ),
inputPath, );
'-map',
'0:a:0',
'-vn',
'-sn',
'-dn',
'-af',
'astats=metadata=1:reset=0',
'-f',
'null',
'-',
]);
final returnCode = await session.getReturnCode(); final returnCode = await session.getReturnCode();
if (!ReturnCode.isSuccess(returnCode)) { if (!ReturnCode.isSuccess(returnCode)) {
return null; return null;
} }
// FFmpegKit delivers logs asynchronously even after the process exits. final metadata = await metadataFile.exists()
// The non-waiting getLogsAsString() can miss the final astats summary. ? await metadataFile.readAsString()
final logs = await session.getAllLogsAsString() ?? ''; : '';
final summary = parseAudioAstatsSummary(logs); final metadataSummary = parseAudioAnalysisMetadata(metadata);
final logs = metadataSummary == null
? await session.getAllLogsAsString() ?? ''
: '';
final summary = metadataSummary ?? parseAudioAstatsSummary(logs);
if (summary == null) return null; if (summary == null) return null;
final channelStats = _parseChannelStats(logs); final channelStats = metadataSummary?.channelStats.isNotEmpty == true
? metadataSummary!.channelStats
: _parseChannelStats(logs);
final clippingSamples = channelStats.fold<int>(0, (sum, stats) { final clippingSamples = channelStats.fold<int>(0, (sum, stats) {
if (stats.peakDb == null || stats.peakDb! < -0.1) return sum; if (stats.peakDb == null || stats.peakDb! < -0.1) return sum;
return sum + stats.peakCount; return sum + stats.peakCount;
@@ -875,56 +1003,15 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
return _LevelMetrics( return _LevelMetrics(
peakDb: summary.peakDb, peakDb: summary.peakDb,
rmsDb: summary.rmsDb, rmsDb: summary.rmsDb,
integratedLufs: metadataSummary?.integratedLufs,
truePeakDb: metadataSummary?.truePeakDb,
clippingSamples: clippingSamples, clippingSamples: clippingSamples,
channelStats: channelStats, channelStats: channelStats,
); );
} finally { } finally {
await FFmpegKitConfig.setLogLevel(Level.avLogError); try {
} if (await metadataFile.exists()) await metadataFile.delete();
} } catch (_) {}
Future<_LoudnessMetrics?> _runLoudnessAnalysis(String inputPath) async {
await FFmpegKitConfig.setLogLevel(Level.avLogInfo);
try {
final session = await FFmpegKit.executeWithArguments([
'-hide_banner',
'-nostats',
'-i',
inputPath,
'-map',
'0:a:0',
'-vn',
'-sn',
'-dn',
'-af',
'ebur128=peak=true:framelog=quiet',
'-f',
'null',
'-',
]);
final logs = await session.getAllLogsAsString() ?? '';
final integratedMatches = RegExp(
r'I:\s+(-?\d+\.?\d*)\s+LUFS',
).allMatches(logs);
final integrated = integratedMatches.isEmpty
? null
: double.tryParse(integratedMatches.last.group(1) ?? '');
double? truePeak;
for (final match in RegExp(
r'Peak:\s+(-?\d+\.?\d*)\s+dBFS',
).allMatches(logs)) {
final value = double.tryParse(match.group(1) ?? '');
if (value != null && (truePeak == null || value > truePeak)) {
truePeak = value;
}
}
if (integrated == null && truePeak == null) return null;
return _LoudnessMetrics(integratedLufs: integrated, truePeakDb: truePeak);
} finally {
await FFmpegKitConfig.setLogLevel(Level.avLogError);
} }
} }
+46
View File
@@ -33,6 +33,51 @@ void main() {
expect(parseAudioAstatsSummary(incompleteLogs), isNull); expect(parseAudioAstatsSummary(incompleteLogs), isNull);
}); });
test('reads per-session metadata without depending on FFmpeg logs', () {
const metadata = '''
lavfi.astats.1.Peak_level=-0.847144
lavfi.astats.1.RMS_level=-12.935500
lavfi.astats.1.Peak_count=2.000000
lavfi.astats.2.Peak_level=-0.861847
lavfi.astats.2.RMS_level=-12.752564
lavfi.astats.2.Peak_count=2.000000
lavfi.astats.Overall.Peak_level=-0.847144
lavfi.astats.Overall.RMS_level=-12.843069
lavfi.r128.I=-9.708
lavfi.r128.true_peak=0.907
''';
final summary = parseAudioAnalysisMetadata(metadata);
expect(summary, isNotNull);
expect(summary!.peakDb, closeTo(-0.847144, 0.000001));
expect(summary.rmsDb, closeTo(-12.843069, 0.000001));
expect(summary.integratedLufs, closeTo(-9.708, 0.000001));
expect(summary.truePeakDb, closeTo(-0.8476, 0.001));
expect(summary.channelStats, hasLength(2));
expect(summary.channelStats.first.channel, 1);
expect(summary.channelStats.first.peakCount, 2);
});
test('keeps the analyzer independent from process-global log level', () {
final arguments = buildAudioMetricsArguments(
inputPath: 'source.flac',
metadataPath: '/tmp/metrics.txt',
durationSeconds: 203.94,
);
expect(arguments, isNot(contains('-v')));
expect(arguments, isNot(contains('-loglevel')));
expect(arguments.join(' '), contains('ametadata=print'));
expect(arguments.join(' '), contains("aselect='gte(t,201.940)'"));
});
test('rejects incomplete per-session metadata instead of using zero', () {
const metadata = 'lavfi.astats.Overall.Peak_level=-0.8';
expect(parseAudioAnalysisMetadata(metadata), isNull);
});
}); });
group('audio spectrogram filter', () { group('audio spectrogram filter', () {
@@ -70,6 +115,7 @@ void main() {
expect(arguments, isNot(contains('-ss'))); expect(arguments, isNot(contains('-ss')));
expect(arguments, isNot(contains('-ar'))); expect(arguments, isNot(contains('-ar')));
expect(arguments, isNot(contains('-ac'))); expect(arguments, isNot(contains('-ac')));
expect(arguments, isNot(contains('-loglevel')));
}); });
}); });