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
@@ -23,6 +23,7 @@ import 'package:spotiflac_android/services/app_state_database.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/services/download_request_payload.dart';
import 'package:spotiflac_android/services/ffmpeg_service.dart';
import 'package:spotiflac_android/services/replaygain_service.dart';
import 'package:spotiflac_android/services/notification_service.dart';
import 'package:spotiflac_android/services/verification_notification.dart';
import 'package:spotiflac_android/utils/logger.dart' hide log;
@@ -563,16 +563,16 @@ extension _DownloadQueueEmbedding on DownloadQueueNotifier {
final rgResult = await FFmpegService.scanReplayGain(filePath);
if (rgResult != null) {
scannedReplayGain = rgResult;
metadata['REPLAYGAIN_TRACK_GAIN'] = rgResult.trackGain;
metadata['REPLAYGAIN_TRACK_PEAK'] = rgResult.trackPeak;
if (format == 'opus') {
final r128 = FFmpegService.replayGainDbToR128(rgResult.trackGain);
if (r128 != null) metadata['R128_TRACK_GAIN'] = r128;
if (format != 'opus') {
metadata['REPLAYGAIN_TRACK_GAIN'] = rgResult.trackGain;
metadata['REPLAYGAIN_TRACK_PEAK'] = rgResult.trackPeak;
}
_log.d(
'ReplayGain for $format: gain=${rgResult.trackGain}, peak=${rgResult.trackPeak}',
);
_storeTrackReplayGainForAlbum(track, filePath, rgResult);
if (format != 'opus') {
_storeTrackReplayGainForAlbum(track, filePath, rgResult);
}
}
} catch (e) {
_log.w('Failed to scan ReplayGain for $format: $e');
@@ -634,10 +634,10 @@ extension _DownloadQueueEmbedding on DownloadQueueNotifier {
// audio through untouched — no FFmpeg spawn, no full container remux,
// no temp-promote copy. The Go side answers method=ffmpeg for files it
// can't handle natively, and any failure falls back to FFmpeg below.
// Scanned ReplayGain (opt-in, non-FLAC) keeps the FFmpeg path: its
// extra tags (e.g. Opus R128_TRACK_GAIN) ride the FFmpeg metadata map.
// Opus ReplayGain is written and verified separately below, through the
// same native R128 writer used by manual scans and album gain updates.
var embeddedNatively = false;
if (scannedReplayGain == null) {
if (scannedReplayGain == null || format == 'opus') {
try {
final nativeFields = <String, String>{
'title': track.name,
@@ -686,7 +686,10 @@ extension _DownloadQueueEmbedding on DownloadQueueNotifier {
filePath,
nativeFields,
);
embeddedNatively = response['method'] != 'ffmpeg';
embeddedNatively =
response['success'] == true &&
response['error'] == null &&
response['method'] != 'ffmpeg';
} catch (e) {
_log.w('Native $format tag embed failed, falling back to FFmpeg: $e');
}
@@ -731,6 +734,19 @@ extension _DownloadQueueEmbedding on DownloadQueueNotifier {
}
}
if (format == 'opus' && scannedReplayGain != null) {
final written = await ReplayGainService.writeTrackTags(
filePath,
scannedReplayGain.trackGain,
scannedReplayGain.trackPeak,
);
if (written) {
_storeTrackReplayGainForAlbum(track, filePath, scannedReplayGain);
} else {
_log.w('Failed to write Opus ReplayGain');
}
}
if (isM4a && settings.embedReplayGain && scannedReplayGain != null) {
try {
await PlatformBridge.editFileMetadata(filePath, {
@@ -1415,7 +1415,6 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier {
actualBitrate = autoConvertBitrateKbps(settings.autoConvertBitrate);
}
await _writeNativeWorkerReplayGain(
context: context,
settings: settings,
track: trackToDownload,
filePath: filePath,
@@ -1547,7 +1546,6 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier {
}
Future<void> _writeNativeWorkerReplayGain({
required _NativeWorkerRequestContext context,
required AppSettings settings,
required Track track,
required String filePath,
@@ -1555,19 +1553,20 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier {
if (!settings.embedReplayGain) {
return;
}
if (context.outputExt != '.flac' && context.outputExt != '.m4a') {
final ext = audioFormatForPath(filePath)?.toLowerCase();
if (ext != 'flac' &&
ext != 'm4a' &&
ext != 'mp3' &&
ext != 'opus' &&
!isContentUri(filePath)) {
return;
}
try {
final rgResult = await FFmpegService.scanReplayGain(filePath);
final rgResult = await ReplayGainService.scanAndApplyToFile(filePath);
if (rgResult == null) {
return;
}
await PlatformBridge.editFileMetadata(filePath, {
'replaygain_track_gain': rgResult.trackGain,
'replaygain_track_peak': rgResult.trackPeak,
});
_storeTrackReplayGainForAlbum(track, filePath, rgResult);
_updateAlbumRgFilePath(track, filePath);
await _checkAndWriteAlbumReplayGain(track);
@@ -172,55 +172,13 @@ extension _DownloadQueueReplayGain on DownloadQueueNotifier {
String albumGain,
String albumPeak,
) async {
final lower = filePath.toLowerCase();
if (lower.endsWith('.flac') ||
lower.endsWith('.ape') ||
lower.endsWith('.wv') ||
lower.endsWith('.mpc')) {
// Native writer — only touches the provided fields, preserves the rest.
await PlatformBridge.editFileMetadata(filePath, {
'replaygain_album_gain': albumGain,
'replaygain_album_peak': albumPeak,
});
} else if (isContentUri(filePath)) {
// SAF content:// URI — FFmpeg can read it but can't write back directly.
// Get the temp output from FFmpeg, then copy it to the SAF URI.
String? tempPath;
final ok = await FFmpegService.writeAlbumReplayGainTags(
filePath,
albumGain,
albumPeak,
returnTempPath: true,
onTempReady: (path) => tempPath = path,
);
if (ok && tempPath != null) {
try {
final safOk = await PlatformBridge.writeTempToSaf(
tempPath!,
filePath,
);
if (!safOk) {
_log.w('SAF write-back failed for album RG: $filePath');
}
} finally {
try {
final tmp = File(tempPath!);
if (await tmp.exists()) await tmp.delete();
} catch (_) {}
}
} else {
_log.w('FFmpeg album ReplayGain write failed for SAF: $filePath');
}
} else {
// Local MP3 / Opus — use FFmpeg copy-with-metadata approach.
final ok = await FFmpegService.writeAlbumReplayGainTags(
filePath,
albumGain,
albumPeak,
);
if (!ok) {
_log.w('FFmpeg album ReplayGain write failed for: $filePath');
}
final ok = await ReplayGainService.writeAlbumTags(
filePath,
albumGain,
albumPeak,
);
if (!ok) {
_log.w('Album ReplayGain write failed for: $filePath');
}
}
+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);