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
+35
View File
@@ -1,7 +1,42 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/services/playback_normalization.dart';
void main() {
test('updated gain replaces the cached volume for the same URI', () async {
var gain = '-4.00 dB';
final cache = PlaybackNormalizationCache(
readMetadata: (_, {displayName}) async => {'replaygain_track_gain': gain},
);
const source = 'content://music/song';
expect(await cache.volumeFor(source), closeTo(0.630957, 0.000001));
gain = '-12.20 dB';
cache.invalidate(source);
expect(await cache.volumeFor(source), closeTo(0.245471, 0.000001));
});
test(
'a read started before an edit cannot restore stale cache data',
() async {
final staleRead = Completer<Map<String, dynamic>>();
var reads = 0;
final cache = PlaybackNormalizationCache(
readMetadata: (_, {displayName}) async {
if (++reads == 1) return staleRead.future;
return {'replaygain_track_gain': '-12.20 dB'};
},
);
final pending = cache.volumeFor('song');
cache.invalidate('song');
await cache.volumeFor('song');
staleRead.complete({'replaygain_track_gain': '-4.00 dB'});
await pending;
expect(await cache.volumeFor('song'), closeTo(0.245471, 0.000001));
expect(reads, 2);
},
);
test(
'descriptor normalization uses hint and caches by original URI',
() async {
+197
View File
@@ -0,0 +1,197 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/services/ffmpeg_service.dart';
import 'package:spotiflac_android/services/replaygain_service.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const channel = MethodChannel('com.zarz.spotiflac/backend');
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
late Directory directory;
late String tempPath;
late List<String> calls;
late Map<String, dynamic> metadata;
late Map<String, String> editedFields;
var method = 'native_ogg';
var applyEdits = true;
var saveSaf = true;
setUp(() async {
directory = await Directory.systemTemp.createTemp('replaygain-test-');
tempPath = '${directory.path}/saf_song.opus';
calls = [];
editedFields = {};
metadata = {
'audio_codec': 'opus',
'replaygain_track_gain': '-4.00 dB',
'replaygain_album_gain': '-3.00 dB',
};
method = 'native_ogg';
applyEdits = true;
saveSaf = true;
messenger.setMockMethodCallHandler(channel, (call) async {
calls.add(call.method);
final args = Map<String, dynamic>.from(call.arguments as Map);
switch (call.method) {
case 'safCopyToTemp':
await File(tempPath).writeAsString('original audio');
return tempPath;
case 'editFileMetadata':
editedFields = Map<String, String>.from(
jsonDecode(args['metadata_json'] as String) as Map,
);
if (applyEdits) {
metadata.addAll(editedFields);
if (metadata['audio_codec'] == 'opus') {
metadata.removeWhere((key, _) => key.endsWith('_peak'));
}
}
return jsonEncode({'success': true, 'method': method});
case 'readFileMetadata':
return jsonEncode(metadata);
case 'writeTempToSaf':
expect(args['temp_path'], tempPath);
expect(await File(tempPath).exists(), isTrue);
return jsonEncode({'success': saveSaf});
default:
fail('Unexpected method: ${call.method}');
}
});
});
tearDown(() async {
messenger.setMockMethodCallHandler(channel, null);
await directory.delete(recursive: true);
});
test(
'manual SAF scan writes and verifies Opus before copying back',
() async {
final result = await ReplayGainService.applyToFile(
'content://music/document/42',
scan: (path) async {
expect(path, tempPath);
expect(await File(path).exists(), isTrue);
calls.add('scan');
return ReplayGainResult(
trackGain: '-12.20 dB',
trackPeak: '1.258925',
integratedLufs: -5.8,
truePeakLinear: 1.258925,
);
},
);
expect(result, isTrue);
expect(calls, [
'safCopyToTemp',
'scan',
'editFileMetadata',
'readFileMetadata',
'writeTempToSaf',
]);
expect(editedFields['replaygain_track_gain'], '-12.20 dB');
expect(await File(tempPath).exists(), isFalse);
},
);
test(
'album gain on an extensionless SAF URI uses the native writer',
() async {
expect(
await ReplayGainService.writeAlbumTags(
'content://music/document/42',
'-10.00 dB',
'1.300000',
),
isTrue,
);
expect(editedFields, {
'replaygain_album_gain': '-10.00 dB',
'replaygain_album_peak': '1.300000',
});
expect(metadata['replaygain_track_gain'], '-4.00 dB');
expect(calls.last, 'writeTempToSaf');
expect(await File(tempPath).exists(), isFalse);
},
);
test('native fallback instruction is not a successful Opus write', () async {
method = 'ffmpeg';
applyEdits = false;
expect(
await ReplayGainService.writeTrackTags(
'content://music/document/42',
'-12.20 dB',
'1.258925',
),
isFalse,
);
expect(calls, ['safCopyToTemp', 'editFileMetadata']);
expect(await File(tempPath).exists(), isFalse);
});
test('unchanged gain fails verification and never overwrites SAF', () async {
applyEdits = false;
expect(
await ReplayGainService.writeTrackTags(
'content://music/document/42',
'-12.20 dB',
'1.258925',
),
isFalse,
);
expect(calls, ['safCopyToTemp', 'editFileMetadata', 'readFileMetadata']);
expect(await File(tempPath).exists(), isFalse);
});
test(
'SAF save failure is reported and the temporary copy is removed',
() async {
saveSaf = false;
expect(
await ReplayGainService.writeTrackTags(
'content://music/document/42',
'-12.20 dB',
'1.258925',
),
isFalse,
);
expect(calls.last, 'writeTempToSaf');
expect(await File(tempPath).exists(), isFalse);
},
);
test(
'scan failure leaves SAF untouched and removes its temporary copy',
() async {
expect(
await ReplayGainService.applyToFile(
'content://music/document/42',
scan: (_) async => null,
),
isFalse,
);
expect(calls, ['safCopyToTemp']);
expect(await File(tempPath).exists(), isFalse);
},
);
test('FLAC native method and peak verification remain supported', () async {
method = 'native';
metadata['audio_codec'] = 'flac';
expect(
await ReplayGainService.writeTrackTags(
'${directory.path}/song.flac',
'-12.20 dB',
'1.258925',
),
isTrue,
);
expect(metadata['replaygain_track_peak'], '1.258925');
expect(calls, ['editFileMetadata', 'readFileMetadata']);
});
}