diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizerFFmpeg.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizerFFmpeg.kt index 5b4b607e..e8bd5b55 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizerFFmpeg.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizerFFmpeg.kt @@ -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() diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 6f87c789..819a4361 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -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": { diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index 7e44eb95..bd8058e2 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -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": { diff --git a/lib/services/batch_track_actions.dart b/lib/services/batch_track_actions.dart index 43f107fe..06752452 100644 --- a/lib/services/batch_track_actions.dart +++ b/lib/services/batch_track_actions.dart @@ -572,6 +572,7 @@ Future runBatchReplayGain( var cancelled = false; int successCount = 0; + var unsupportedDecoder = false; final total = selectedItems.length; BatchProgressDialog.show( @@ -590,7 +591,10 @@ Future 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 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'), + ), ), ); } diff --git a/lib/services/ffmpeg_service.dart b/lib/services/ffmpeg_service.dart index 1281f735..2893f354 100644 --- a/lib/services/ffmpeg_service.dart +++ b/lib/services/ffmpeg_service.dart @@ -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 scanReplayGain(String filePath) async { + static Future 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', diff --git a/lib/services/replaygain_service.dart b/lib/services/replaygain_service.dart index fa5dd4cf..9ed89c9c 100644 --- a/lib/services/replaygain_service.dart +++ b/lib/services/replaygain_service.dart @@ -48,16 +48,29 @@ class ReplayGainService { static Future applyToFile( String filePath, { @visibleForTesting Future 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 scanAndApplyToFile( String filePath, { @visibleForTesting Future 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; diff --git a/test/ffmpeg_replaygain_scan_test.dart b/test/ffmpeg_replaygain_scan_test.dart new file mode 100644 index 00000000..a4120377 --- /dev/null +++ b/test/ffmpeg_replaygain_scan_test.dart @@ -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)); + }); +}