fix(replaygain): write and verify native Opus gain tags

Preserve audio and artwork while replacing conflicting Opus gain tags with R128 comments. Verify manual, download, and album writes, handle SAF failures, and refresh playback normalization after saving.
This commit is contained in:
zarzet
2026-09-06 21:03:57 +07:00
parent 0acdd6d0b0
commit bfffb8da11
14 changed files with 745 additions and 138 deletions
+13 -30
View File
@@ -1057,22 +1057,6 @@ class FFmpegService {
);
}
/// Convert a ReplayGain gain value (dB, referenced to -18 LUFS) into an Opus
/// R128 gain tag value (Q7.8 fixed point integer, referenced to -23 LUFS).
///
/// Opus players read `R128_TRACK_GAIN` / `R128_ALBUM_GAIN` per RFC 7845, not
/// the `REPLAYGAIN_*` dB strings. The reference levels differ by exactly 5 dB
/// (-18 vs -23 LUFS), so the R128 gain equals the ReplayGain value minus 5 dB,
/// stored as `round(dB * 256)`.
static String? replayGainDbToR128(String replayGainDb) {
final match = RegExp(r'-?\d+\.?\d*').firstMatch(replayGainDb);
if (match == null) return null;
final rgDb = double.tryParse(match.group(0) ?? '');
if (rgDb == null) return null;
final r128Db = rgDb - 5.0;
return (r128Db * 256).round().toString();
}
/// Write album ReplayGain tags to a file via FFmpeg.
///
/// For local files, replaces the file in-place and returns `true`.
@@ -1097,10 +1081,9 @@ class FFmpegService {
/// Write track ReplayGain tags to a file via FFmpeg, replacing it in place.
///
/// Used for formats that are not handled by the native tag writers
/// (MP3/Opus). All existing streams and metadata are preserved via
/// `-map 0 -c copy -map_metadata 0`; only the REPLAYGAIN_TRACK_* fields are
/// added/overwritten. Returns `true` when the file was rewritten in place.
/// Used as a fallback for formats other than Ogg/Opus. Copies streams and
/// metadata with `-map 0 -c copy -map_metadata 0`, setting track gain and peak.
/// The caller verifies the tags after a successful rewrite.
static Future<bool> writeTrackReplayGainTags(
String filePath,
String trackGain,
@@ -1108,7 +1091,7 @@ class FFmpegService {
) => _writeReplayGainTags(filePath, 'Track', trackGain, trackPeak);
/// Shared implementation for album/track ReplayGain tagging.
/// [scope] is 'Album' or 'Track'; it selects the REPLAYGAIN_*/R128_* tags.
/// [scope] is 'Album' or 'Track'; it selects the REPLAYGAIN_* tags.
static Future<bool> _writeReplayGainTags(
String filePath,
String scope,
@@ -1120,6 +1103,10 @@ class FFmpegService {
final ext = filePath.contains('.')
? '.${filePath.split('.').last}'
: '.tmp';
if (ext.toLowerCase() == '.opus' || ext.toLowerCase() == '.ogg') {
_log.e('Ogg/Opus ReplayGain requires the native tag writer');
return false;
}
final tempDir = await getTemporaryDirectory();
final tempOutput = _nextTempEmbedPath(tempDir.path, ext);
final tag = scope.toUpperCase();
@@ -1141,15 +1128,6 @@ class FFmpegService {
'REPLAYGAIN_${tag}_PEAK=$peak',
];
if (ext.toLowerCase() == '.opus') {
final r128 = replayGainDbToR128(gain);
if (r128 != null) {
arguments
..add('-metadata')
..add('R128_${tag}_GAIN=$r128');
}
}
arguments
..add(tempOutput)
..add('-y');
@@ -1157,6 +1135,11 @@ class FFmpegService {
_log.d('Writing ${scope.toLowerCase()} ReplayGain tags via FFmpeg');
final result = await _executeWithArguments(arguments);
if (!result.success) {
_log.e(
'$scope ReplayGain write failed (code ${result.returnCode}): ${result.output}',
);
}
if (result.success) {
if (returnTempPath) {
try {
+30 -1
View File
@@ -37,6 +37,12 @@ void setPlaybackNormalizationEnabled(bool enabled) {
_activeMusicPlayerHandler?.reapplyNormalization();
}
/// Refreshes gain tags after a successful file update, including SAF copies.
void refreshPlaybackNormalization(String source) {
final handler = _activeMusicPlayerHandler;
if (handler != null) unawaited(handler._refreshNormalizationSource(source));
}
List<int> buildShuffleCandidatePool({
required int mediaCount,
required int currentIndex,
@@ -556,6 +562,24 @@ class MusicPlayerHandler extends BaseAudioHandler
onReadError: (error) =>
_log.w('Failed to read gain tags for normalization: $error'),
);
int _normalizationGeneration = 0;
Future<void> _refreshNormalizationSource(String source) async {
_normalizationGeneration++;
_normalizationCache.invalidate(source);
// A copy started before the edit can still contain the old comments.
await _pendingSourceResolutions[source];
final oldPath = _resolvedPathCache.remove(source);
_resolvedPathSizes.remove(source);
_resolvedPathOrder.remove(source);
if (oldPath != null) await _discardResolvedPath(oldPath);
if (_disposed) return;
if (_index >= 0 &&
_index < _media.length &&
_media[_index].source == source) {
reapplyNormalization();
}
}
Future<double> _normalizationVolumeFor(
String path, {
@@ -574,6 +598,7 @@ class MusicPlayerHandler extends BaseAudioHandler
void reapplyNormalization() {
final index = _index;
final generation = _playRequestGeneration;
final normalizationGeneration = ++_normalizationGeneration;
if (index < 0 || index >= _media.length) return;
unawaited(() async {
final media = _media[index];
@@ -599,7 +624,11 @@ class MusicPlayerHandler extends BaseAudioHandler
if (playbackLease != null) {
await PlatformBridge.closeContentUriPlaybackLease(playbackLease.token);
}
if (_index != index || generation != _playRequestGeneration) return;
if (_index != index ||
generation != _playRequestGeneration ||
normalizationGeneration != _normalizationGeneration) {
return;
}
try {
await _player.setVolume(volume);
} catch (e) {
+11 -2
View File
@@ -7,10 +7,16 @@ class PlaybackNormalizationCache {
readMetadata;
final void Function(Object)? onReadError;
final Map<String, double> _volumes = {};
int _generation = 0;
static final _gainNumber = RegExp(r'-?\d+(\.\d+)?');
PlaybackNormalizationCache({required this.readMetadata, this.onReadError});
void invalidate(String source) {
_volumes.remove(source);
_generation++;
}
Future<double> volumeFor(
String path, {
String? cacheKey,
@@ -19,6 +25,7 @@ class PlaybackNormalizationCache {
final key = cacheKey ?? path;
final cached = _volumes[key];
if (cached != null) return cached;
final generation = _generation;
try {
final metadata = await readMetadata(path, displayName: displayName);
if (metadata['error'] != null) {
@@ -31,8 +38,10 @@ class PlaybackNormalizationCache {
final volume = gain == null
? 1.0
: pow(10.0, gain / 20.0).toDouble().clamp(0.0, 1.0);
if (_volumes.length >= 128) _volumes.remove(_volumes.keys.first);
_volumes[key] = volume;
if (generation == _generation) {
if (_volumes.length >= 128) _volumes.remove(_volumes.keys.first);
_volumes[key] = volume;
}
return volume;
} catch (error) {
onReadError?.call(error);
+125 -31
View File
@@ -1,16 +1,16 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:spotiflac_android/services/ffmpeg_service.dart';
import 'package:spotiflac_android/services/music_player_service.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/logger.dart';
/// Standalone ReplayGain (re)scanning for existing audio files.
///
/// Computes EBU R128 loudness via FFmpeg and writes REPLAYGAIN_TRACK_* tags
/// back into the file in place:
/// - FLAC / M4A / MP4 / APE / WV / MPC -> native tag writer (PlatformBridge)
/// - MP3 / Opus / OGG / others -> FFmpeg copy-with-metadata
/// Computes EBU R128 loudness via FFmpeg and writes gain tags using the native
/// metadata editors where supported. Opus uses R128_* rather than legacy tags.
///
/// Handles SAF content:// URIs transparently by working on a temporary copy
/// and writing it back to the original document.
@@ -31,6 +31,9 @@ class ReplayGainService {
'.aiff',
'.aif',
'.aifc',
'.mp3',
'.opus',
'.ogg',
};
static bool _isNativeWritableFormat(String path) {
@@ -42,7 +45,120 @@ class ReplayGainService {
///
/// Returns `true` when tags were successfully written, `false` otherwise
/// (scan failed, write failed, or SAF write-back failed).
static Future<bool> applyToFile(String filePath) async {
static Future<bool> applyToFile(
String filePath, {
@visibleForTesting Future<ReplayGainResult?> Function(String)? scan,
}) async => await scanAndApplyToFile(filePath, scan: scan) != null;
/// Returns the scan for album aggregation only after a verified save.
static Future<ReplayGainResult?> scanAndApplyToFile(
String filePath, {
@visibleForTesting Future<ReplayGainResult?> Function(String)? scan,
}) async {
ReplayGainResult? scanned;
final written = await _updateFile(filePath, (workingPath) async {
final rg = await (scan ?? FFmpegService.scanReplayGain)(workingPath);
if (rg == null) {
_log.w('ReplayGain scan returned no result for $workingPath');
return false;
}
scanned = rg;
return _writeLocalTags(
workingPath,
rg.trackGain,
rg.trackPeak,
album: false,
);
});
return written ? scanned : null;
}
static Future<bool> writeTrackTags(
String filePath,
String gain,
String peak,
) => _updateFile(
filePath,
(path) => _writeLocalTags(path, gain, peak, album: false),
);
static Future<bool> writeAlbumTags(
String filePath,
String gain,
String peak,
) => _updateFile(
filePath,
(path) => _writeLocalTags(path, gain, peak, album: true),
);
static Future<bool> _writeLocalTags(
String path,
String gain,
String peak, {
required bool album,
}) async {
final scope = album ? 'album' : 'track';
var written = false;
if (_isNativeWritableFormat(path)) {
final result = await PlatformBridge.editFileMetadata(path, {
'replaygain_${scope}_gain': gain,
'replaygain_${scope}_peak': peak,
});
written =
result['success'] == true &&
result['error'] == null &&
result['method'] is String &&
(result['method'] == 'native' ||
(result['method'] as String).startsWith('native_'));
if (!written) {
_log.w('Native $scope ReplayGain write did not complete: $result');
}
}
if (!written) {
// Remuxing all streams rejects attached Opus artwork; mapping only audio
// would discard it. Keep the original if its native editor cannot handle
// the file, rather than silently losing artwork or reporting a no-op.
final lower = path.toLowerCase();
if (lower.endsWith('.opus') || lower.endsWith('.ogg')) return false;
written = album
? await FFmpegService.writeAlbumReplayGainTags(path, gain, peak)
: await FFmpegService.writeTrackReplayGainTags(path, gain, peak);
}
if (!written) return false;
final metadata = await PlatformBridge.readFileMetadata(path);
final expectedGain = _gainDb(gain);
final actualGain = _gainDb(metadata['replaygain_${scope}_gain']);
final expectedPeak = double.tryParse(peak);
final actualPeak = double.tryParse(
metadata['replaygain_${scope}_peak']?.toString() ?? '',
);
final isOpus = metadata['audio_codec'] == 'opus';
final verified =
metadata['error'] == null &&
expectedGain != null &&
actualGain != null &&
(actualGain - expectedGain).abs() <= 0.01 &&
(isOpus ||
(expectedPeak != null &&
actualPeak != null &&
(actualPeak - expectedPeak).abs() <= 0.000001));
if (!verified) {
_log.w('$scope ReplayGain verification failed after writing $path');
return false;
}
return true;
}
static double? _gainDb(Object? value) => double.tryParse(
(value?.toString() ?? '').replaceFirst(RegExp(r'\s*dB\s*$'), '').trim(),
);
static Future<bool> _updateFile(
String filePath,
Future<bool> Function(String) update,
) async {
if (filePath.isEmpty) return false;
final isSaf = isContentUri(filePath);
@@ -59,40 +175,18 @@ class ReplayGainService {
workingPath = safTempPath;
}
final rg = await FFmpegService.scanReplayGain(workingPath);
if (rg == null) {
_log.w('ReplayGain scan returned no result for $workingPath');
return false;
}
bool written;
if (_isNativeWritableFormat(workingPath)) {
final result = await PlatformBridge.editFileMetadata(workingPath, {
'replaygain_track_gain': rg.trackGain,
'replaygain_track_peak': rg.trackPeak,
});
written = result['error'] == null;
if (!written) {
_log.w('Native ReplayGain write failed: ${result['error']}');
}
} else {
written = await FFmpegService.writeTrackReplayGainTags(
workingPath,
rg.trackGain,
rg.trackPeak,
);
}
if (!written) return false;
if (!await update(workingPath)) return false;
if (isSaf) {
final ok = await PlatformBridge.writeTempToSaf(workingPath, filePath);
if (!ok) {
_log.w('Failed to write ReplayGain temp file back to SAF document');
return false;
}
return ok;
}
refreshPlaybackNormalization(filePath);
_log.i('ReplayGain tags written and verified: $filePath');
return true;
} catch (e) {
_log.e('Failed to apply ReplayGain', e);