fix(replaygain): report decoder failures and reject incomplete scans

This commit is contained in:
zarzet
2026-09-07 16:36:50 +07:00
parent e3f7817b29
commit c27dc7c1b6
7 changed files with 147 additions and 13 deletions
@@ -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()
+1
View File
@@ -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": {
+1
View File
@@ -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": {
+11 -2
View File
@@ -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'),
),
),
);
}
+58 -8
View File
@@ -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',
+15 -2
View File
@@ -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;
+51
View File
@@ -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));
});
}