mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-26 19:31:45 +02:00
fix(analysis): isolate metrics from active downloads
This commit is contained in:
@@ -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});
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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')));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user