mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-15 06:15:28 +02:00
fix(replaygain): report decoder failures and reject incomplete scans
This commit is contained in:
@@ -63,9 +63,18 @@ internal fun NativeDownloadFinalizer.formatForPath(path: String): String {
|
||||
}
|
||||
|
||||
internal fun NativeDownloadFinalizer.scanReplayGain(path: String, shouldCancel: () -> Boolean = { false }): NativeDownloadFinalizer.ReplayGainScan? {
|
||||
val command = "-hide_banner -nostats -i ${q(path)} -filter_complex ebur128=peak=true:framelog=quiet -f null -"
|
||||
val command = "-hide_banner -nostats -loglevel info -i ${q(path)} -map 0:a:0 -vn -sn -dn -af ebur128=peak=true:framelog=quiet -f null -"
|
||||
val result = runFFmpeg(command, shouldCancel)
|
||||
val output = result.second
|
||||
if (!result.first) {
|
||||
val diagnostic = output.lineSequence()
|
||||
.filter { Regex("decoder|error|invalid|failed", RegexOption.IGNORE_CASE).containsMatchIn(it) }
|
||||
.take(3)
|
||||
.joinToString(" ")
|
||||
.take(500)
|
||||
Log.w(TAG, "ReplayGain scan failed: $diagnostic")
|
||||
return null
|
||||
}
|
||||
val integrated = Regex("I:\\s+(-?\\d+\\.?\\d*)\\s+LUFS")
|
||||
.findAll(output)
|
||||
.lastOrNull()
|
||||
|
||||
@@ -237,6 +237,7 @@
|
||||
"description": "Progress dialog title while batch scanning ReplayGain"
|
||||
},
|
||||
"replayGainBatchSuccess": "ReplayGain added to {success} of {total} tracks",
|
||||
"replayGainUnsupportedDecoder": "Some tracks could not be analyzed because their audio codec is not supported by the ReplayGain decoder. Their files were not changed.",
|
||||
"@replayGainBatchSuccess": {
|
||||
"description": "Snackbar after batch ReplayGain completes",
|
||||
"placeholders": {
|
||||
|
||||
@@ -150,6 +150,7 @@
|
||||
"description": "Button to install extension from file"
|
||||
},
|
||||
"replayGainBatchSuccess": "ReplayGain ditambahkan ke {success} dari {total} trek",
|
||||
"replayGainUnsupportedDecoder": "Sebagian trek tidak dapat dianalisis karena codec audionya belum didukung decoder ReplayGain. File trek tersebut tidak diubah.",
|
||||
"@replayGainBatchSuccess": {
|
||||
"description": "Snackbar after batch ReplayGain completes",
|
||||
"placeholders": {
|
||||
|
||||
@@ -572,6 +572,7 @@ Future<void> runBatchReplayGain(
|
||||
|
||||
var cancelled = false;
|
||||
int successCount = 0;
|
||||
var unsupportedDecoder = false;
|
||||
final total = selectedItems.length;
|
||||
|
||||
BatchProgressDialog.show(
|
||||
@@ -590,7 +591,10 @@ Future<void> runBatchReplayGain(
|
||||
final item = selectedItems[i];
|
||||
BatchProgressDialog.update(current: i + 1, detail: item.trackName);
|
||||
try {
|
||||
final ok = await ReplayGainService.applyToFile(item.filePath);
|
||||
final ok = await ReplayGainService.applyToFile(
|
||||
item.filePath,
|
||||
onUnsupportedDecoder: () => unsupportedDecoder = true,
|
||||
);
|
||||
if (ok) successCount++;
|
||||
} catch (_) {}
|
||||
}
|
||||
@@ -604,7 +608,12 @@ Future<void> runBatchReplayGain(
|
||||
ScaffoldMessenger.of(context).clearSnackBars();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.replayGainBatchSuccess(successCount, total)),
|
||||
content: Text(
|
||||
[
|
||||
context.l10n.replayGainBatchSuccess(successCount, total),
|
||||
if (unsupportedDecoder) context.l10n.replayGainUnsupportedDecoder,
|
||||
].join('\n'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -927,23 +927,73 @@ class FFmpegService {
|
||||
/// Uses the FFmpeg `ebur128` audio filter to measure integrated loudness (LUFS)
|
||||
/// and true peak. ReplayGain reference level is -18 LUFS (≈ 89 dB SPL).
|
||||
///
|
||||
static Future<ReplayGainResult?> scanReplayGain(String filePath) async {
|
||||
static Future<ReplayGainResult?> scanReplayGain(
|
||||
String filePath, {
|
||||
void Function()? onUnsupportedDecoder,
|
||||
}) async {
|
||||
// -nostats suppresses the interactive progress line.
|
||||
// ebur128=peak=true prints integrated loudness + true peak.
|
||||
// framelog=quiet suppresses per-frame measurements (very verbose),
|
||||
// keeping only the final summary which we parse.
|
||||
final command =
|
||||
'-hide_banner -nostats -i "$filePath" -filter_complex ebur128=peak=true:framelog=quiet -f null -';
|
||||
|
||||
_log.d(
|
||||
'Scanning ReplayGain for: ${filePath.split(Platform.pathSeparator).last}',
|
||||
);
|
||||
final result = await _execute(command);
|
||||
final result = await _executeWithArguments([
|
||||
'-hide_banner',
|
||||
'-nostats',
|
||||
'-loglevel',
|
||||
'info',
|
||||
'-i',
|
||||
filePath,
|
||||
'-map',
|
||||
'0:a:0',
|
||||
'-vn',
|
||||
'-sn',
|
||||
'-dn',
|
||||
'-af',
|
||||
'ebur128=peak=true:framelog=quiet',
|
||||
'-f',
|
||||
'null',
|
||||
'-',
|
||||
]);
|
||||
return parseReplayGainScan(
|
||||
result,
|
||||
onUnsupportedDecoder: onUnsupportedDecoder,
|
||||
);
|
||||
}
|
||||
|
||||
// FFmpeg writes ebur128 stats to stderr, which ends up in the output.
|
||||
// Even on "failure" return code, the output may contain valid data
|
||||
// because -f null always "fails" on some FFmpeg builds.
|
||||
@visibleForTesting
|
||||
static ReplayGainResult? parseReplayGainScan(
|
||||
FFmpegResult result, {
|
||||
void Function()? onUnsupportedDecoder,
|
||||
}) {
|
||||
final output = result.output;
|
||||
if (!result.success) {
|
||||
final decoderMissing = RegExp(
|
||||
r'decoder.*not found|no decoder found|unknown decoder',
|
||||
caseSensitive: false,
|
||||
);
|
||||
final diagnostic = output
|
||||
.split('\n')
|
||||
.where(
|
||||
(line) =>
|
||||
decoderMissing.hasMatch(line) ||
|
||||
RegExp(
|
||||
r'error|invalid|failed',
|
||||
caseSensitive: false,
|
||||
).hasMatch(line),
|
||||
)
|
||||
.take(3)
|
||||
.join(' ')
|
||||
.trim();
|
||||
_log.w(
|
||||
'ReplayGain scan failed (exit ${result.returnCode}): ${diagnostic.isEmpty ? "no decoder diagnostics" : diagnostic.substring(0, math.min(diagnostic.length, 500))}',
|
||||
);
|
||||
if (decoderMissing.hasMatch(output)) onUnsupportedDecoder?.call();
|
||||
// A failed decoder/filter can still print a partial/default summary.
|
||||
// It is not a valid measurement of the complete track.
|
||||
return null;
|
||||
}
|
||||
|
||||
final integratedMatch = RegExp(
|
||||
r'I:\s+(-?\d+\.?\d*)\s+LUFS',
|
||||
|
||||
@@ -48,16 +48,29 @@ class ReplayGainService {
|
||||
static Future<bool> applyToFile(
|
||||
String filePath, {
|
||||
@visibleForTesting Future<ReplayGainResult?> Function(String)? scan,
|
||||
}) async => await scanAndApplyToFile(filePath, scan: scan) != null;
|
||||
void Function()? onUnsupportedDecoder,
|
||||
}) async =>
|
||||
await scanAndApplyToFile(
|
||||
filePath,
|
||||
scan: scan,
|
||||
onUnsupportedDecoder: onUnsupportedDecoder,
|
||||
) !=
|
||||
null;
|
||||
|
||||
/// Returns the scan for album aggregation only after a verified save.
|
||||
static Future<ReplayGainResult?> scanAndApplyToFile(
|
||||
String filePath, {
|
||||
@visibleForTesting Future<ReplayGainResult?> Function(String)? scan,
|
||||
void Function()? onUnsupportedDecoder,
|
||||
}) async {
|
||||
ReplayGainResult? scanned;
|
||||
final written = await _updateFile(filePath, (workingPath) async {
|
||||
final rg = await (scan ?? FFmpegService.scanReplayGain)(workingPath);
|
||||
final rg = scan != null
|
||||
? await scan(workingPath)
|
||||
: await FFmpegService.scanReplayGain(
|
||||
workingPath,
|
||||
onUnsupportedDecoder: onUnsupportedDecoder,
|
||||
);
|
||||
if (rg == null) {
|
||||
_log.w('ReplayGain scan returned no result for $workingPath');
|
||||
return false;
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:spotiflac_android/services/ffmpeg_service.dart';
|
||||
|
||||
void main() {
|
||||
test('missing decoder reports unsupported and rejects a default summary', () {
|
||||
var unsupported = false;
|
||||
final result = FFmpegService.parseReplayGainScan(
|
||||
FFmpegResult(
|
||||
success: false,
|
||||
returnCode: 1,
|
||||
output:
|
||||
'Decoder (codec ac4) not found for input stream #0:0\n'
|
||||
'I: -70.0 LUFS\nPeak: -120.0 dBFS',
|
||||
),
|
||||
onUnsupportedDecoder: () => unsupported = true,
|
||||
);
|
||||
expect(result, isNull);
|
||||
expect(unsupported, isTrue);
|
||||
});
|
||||
|
||||
test('failed decoding cannot use a partial loudness measurement', () {
|
||||
var unsupported = false;
|
||||
final result = FFmpegService.parseReplayGainScan(
|
||||
FFmpegResult(
|
||||
success: false,
|
||||
returnCode: 1,
|
||||
output:
|
||||
'Error while decoding stream #0:0: Invalid data\n'
|
||||
'I: -12.0 LUFS\nPeak: -1.0 dBFS',
|
||||
),
|
||||
onUnsupportedDecoder: () => unsupported = true,
|
||||
);
|
||||
expect(result, isNull);
|
||||
expect(unsupported, isFalse);
|
||||
});
|
||||
|
||||
test('completed analysis uses the final summary and highest peak', () {
|
||||
final result = FFmpegService.parseReplayGainScan(
|
||||
FFmpegResult(
|
||||
success: true,
|
||||
returnCode: 0,
|
||||
output:
|
||||
'I: -70.0 LUFS\nI: -12.0 LUFS\n'
|
||||
'Peak: -2.0 dBFS\nPeak: -1.0 dBFS',
|
||||
),
|
||||
);
|
||||
expect(result, isNotNull);
|
||||
expect(result!.trackGain, '-6.00 dB');
|
||||
expect(result.truePeakLinear, closeTo(0.891251, 0.000001));
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user