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
@@ -30,6 +30,7 @@ import java.util.concurrent.CancellationException
import java.util.concurrent.CountDownLatch import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import kotlin.math.abs
import kotlin.math.pow import kotlin.math.pow
object NativeDownloadFinalizer { object NativeDownloadFinalizer {
@@ -251,8 +252,17 @@ object NativeDownloadFinalizer {
result.put("auto_conversion_warning", e.message ?: "conversion failed") result.put("auto_conversion_warning", e.message ?: "conversion failed")
} }
checkCancelled(shouldCancel) checkCancelled(shouldCancel)
val replayGain = writeReplayGain(context, effectiveInput, state, shouldCancel) try {
if (replayGain != null) result.put("replaygain", replayGain) val replayGain = writeReplayGain(context, effectiveInput, state, shouldCancel)
if (replayGain != null) result.put("replaygain", replayGain)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
// Gain tagging is optional; keep the completed audio if
// its native editor or the verification step fails.
Log.w(TAG, "ReplayGain write failed: ${e.message}")
result.put("replaygain_warning", e.message ?: "ReplayGain write failed")
}
checkCancelled(shouldCancel) checkCancelled(shouldCancel)
try { try {
refreshFinalAudioQualityMetadata(context, result, state) refreshFinalAudioQualityMetadata(context, result, state)
@@ -982,14 +992,14 @@ object NativeDownloadFinalizer {
private fun writeReplayGainFields(context: Context, path: String, fields: JSONObject) { private fun writeReplayGainFields(context: Context, path: String, fields: JSONObject) {
if (!path.startsWith("content://")) { if (!path.startsWith("content://")) {
Gobackend.editFileMetadata(path, fields.toString()) writeLocalReplayGainFields(path, fields)
return return
} }
val tempPath = SafDownloadHandler.copyContentUriToTemp(context, path) val tempPath = SafDownloadHandler.copyContentUriToTemp(context, path)
?: throw IllegalStateException("failed to copy SAF file for ReplayGain write") ?: throw IllegalStateException("failed to copy SAF file for ReplayGain write")
try { try {
Gobackend.editFileMetadata(tempPath, fields.toString()) writeLocalReplayGainFields(tempPath, fields)
val uri = Uri.parse(path) val uri = Uri.parse(path)
context.contentResolver.openOutputStream(uri, "wt")?.use { output -> context.contentResolver.openOutputStream(uri, "wt")?.use { output ->
File(tempPath).inputStream().use { input -> input.copyTo(output) } File(tempPath).inputStream().use { input -> input.copyTo(output) }
@@ -1000,6 +1010,30 @@ object NativeDownloadFinalizer {
} }
} }
private fun writeLocalReplayGainFields(path: String, fields: JSONObject) {
val result = parseObject(Gobackend.editFileMetadata(path, fields.toString()))
val method = result.optString("method", "")
check(
result.optBoolean("success", false) &&
!result.has("error") &&
(method == "native" || method.startsWith("native_")),
) { "ReplayGain native write did not complete: $result" }
val metadata = parseObject(Gobackend.readFileMetadata(path))
check(!metadata.has("error")) { "ReplayGain verification failed: $metadata" }
val isOpus = metadata.optString("audio_codec", "") == "opus"
for (key in fields.keys()) {
// Opus stores only R128 gain, exposed by the Go reader as dB.
if (isOpus && key.endsWith("_peak")) continue
val expected = fields.optString(key, "").trim().removeSuffix("dB").trim().toDoubleOrNull()
val actual = metadata.optString(key, "").trim().removeSuffix("dB").trim().toDoubleOrNull()
val tolerance = if (key.endsWith("_gain")) 0.01 else 0.000001
check(expected != null && actual != null && abs(actual - expected) <= tolerance) {
"ReplayGain verification failed for $key"
}
}
}
private fun refreshFinalAudioQualityMetadata(context: Context, result: JSONObject, state: FinalizeState) { private fun refreshFinalAudioQualityMetadata(context: Context, result: JSONObject, state: FinalizeState) {
if (!supportsAudioMetadataProbe(state.filePath, state.fileName)) return if (!supportsAudioMetadataProbe(state.filePath, state.fileName)) return
+4 -3
View File
@@ -48,7 +48,8 @@ func ReadOggVorbisComments(filePath string) (*AudioMetadata, error) {
} }
} }
if metadata.Title == "" && metadata.Artist == "" { if metadata.Title == "" && metadata.Artist == "" &&
metadata.ReplayGainTrackGain == "" && metadata.ReplayGainAlbumGain == "" {
return nil, fmt.Errorf("no Vorbis comments found") return nil, fmt.Errorf("no Vorbis comments found")
} }
@@ -314,9 +315,9 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
// r128ToReplayGainDb converts an R128_*_GAIN value (integer, 1/256 dB steps, // r128ToReplayGainDb converts an R128_*_GAIN value (integer, 1/256 dB steps,
// -23 LUFS reference) to a ReplayGain 2 dB string (-18 LUFS reference): // -23 LUFS reference) to a ReplayGain 2 dB string (-18 LUFS reference):
// rg = q/256 + 5. Inverse of the writer's replayGainDbToR128. // rg = q/256 + 5. Inverse of applyOpusReplayGainEdits.
func r128ToReplayGainDb(raw string) (string, bool) { func r128ToReplayGainDb(raw string) (string, bool) {
q, err := strconv.Atoi(strings.TrimSpace(raw)) q, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 16)
if err != nil { if err != nil {
return "", false return "", false
} }
+35
View File
@@ -7,8 +7,10 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"math"
"os" "os"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
flacvorbis "github.com/go-flac/flacvorbis/v2" flacvorbis "github.com/go-flac/flacvorbis/v2"
@@ -350,6 +352,12 @@ func EditOggFields(filePath string, fields map[string]string) error {
cmt.Vendor = vendor cmt.Vendor = vendor
cmt.Comments = comments cmt.Comments = comments
applyVorbisFieldEdits(cmt, fields) applyVorbisFieldEdits(cmt, fields)
if isOpus {
if err := applyOpusReplayGainEdits(cmt, fields); err != nil {
f.Close()
return err
}
}
coverPath := strings.TrimSpace(fields["cover_path"]) coverPath := strings.TrimSpace(fields["cover_path"])
if coverPath != "" && fileExists(coverPath) { if coverPath != "" && fileExists(coverPath) {
@@ -446,3 +454,30 @@ func EditOggFields(filePath string, fields map[string]string) error {
syncDir(filepath.Dir(filePath)) syncDir(filepath.Dir(filePath))
return nil return nil
} }
// Opus uses R128 gain comments relative to -23 LUFS, in signed Q7.8 units.
// The scan already includes OpusHead's output gain; preserve that header and
// store only the additional adjustment. Remove legacy tags for edited scopes
// so players cannot select conflicting gains or peaks (RFC 7845 section 5.2).
func applyOpusReplayGainEdits(cmt *flacvorbis.MetaDataBlockVorbisComment, fields map[string]string) error {
for _, scope := range []string{"track", "album"} {
raw, present := fields["replaygain_"+scope+"_gain"]
if !present {
continue
}
value := ""
if raw = strings.TrimSpace(raw); raw != "" {
db, err := strconv.ParseFloat(strings.TrimSpace(strings.TrimSuffix(raw, "dB")), 64)
q := math.Round((db - 5) * 256)
if err != nil || math.IsNaN(q) || math.IsInf(q, 0) || q < -32768 || q > 32767 {
return fmt.Errorf("invalid Opus %s ReplayGain: %q", scope, raw)
}
value = strconv.Itoa(int(q))
}
upper := strings.ToUpper(scope)
setOrClearComment(cmt, "R128_"+upper+"_GAIN", value)
removeCommentKey(cmt, "REPLAYGAIN_"+upper+"_GAIN")
removeCommentKey(cmt, "REPLAYGAIN_"+upper+"_PEAK")
}
return nil
}
+216
View File
@@ -0,0 +1,216 @@
package gobackend
import (
"bytes"
"encoding/base64"
"encoding/binary"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func readReplayGainOggPages(t *testing.T, path string) []oggEditPage {
t.Helper()
f, err := os.Open(path)
if err != nil {
t.Fatal(err)
}
defer f.Close()
pages, err := readAllOggEditPages(f)
if err != nil {
t.Fatal(err)
}
return pages
}
func TestOpusReplayGainReplacesLegacyTagsAndPreservesAudio(t *testing.T) {
for _, ext := range []string{".opus", ".ogg"} {
t.Run(ext, func(t *testing.T) {
path := filepath.Join(t.TempDir(), "song"+ext)
buildTestOpus(t, path, []string{
"TITLE=Example Song", "CUSTOM=Keep me", "METADATA_BLOCK_PICTURE=existing-picture",
"REPLAYGAIN_TRACK_GAIN=-4.00 dB", "REPLAYGAIN_TRACK_PEAK=0.800000",
"R128_TRACK_GAIN=-2304", "r128_track_gain=100",
"REPLAYGAIN_ALBUM_GAIN=-3.00 dB", "REPLAYGAIN_ALBUM_PEAK=0.900000",
"R128_ALBUM_GAIN=-2048",
}, 3)
// Preserve a nonzero OpusHead output gain as well as every audio page.
pages := readReplayGainOggPages(t, path)
binary.LittleEndian.PutUint16(pages[0].data[16:18], 256)
var original bytes.Buffer
for _, page := range pages {
if err := page.serialize(&original); err != nil {
t.Fatal(err)
}
}
if err := os.WriteFile(path, original.Bytes(), 0o600); err != nil {
t.Fatal(err)
}
for _, scope := range []string{"track", "album"} {
fields, _ := json.Marshal(map[string]string{
"replaygain_" + scope + "_gain": "-12.20 dB",
"replaygain_" + scope + "_peak": "1.258925",
})
result, err := EditFileMetadata(path, string(fields))
if err != nil || !strings.Contains(result, "native_ogg") {
t.Fatalf("write: %s, %v", result, err)
}
metadata, err := ReadFileMetadata(path)
if err != nil {
t.Fatal(err)
}
var readback map[string]any
if err := json.Unmarshal([]byte(metadata), &readback); err != nil {
t.Fatal(err)
}
if got := readback["replaygain_"+scope+"_gain"]; got != "-12.20 dB" {
t.Fatalf("%s gain = %v", scope, got)
}
raw := mustReadFile(t, path)
upper := strings.ToUpper(scope)
if bytes.Count(bytes.ToUpper(raw), []byte("R128_"+upper+"_GAIN=")) != 1 ||
!bytes.Contains(raw, []byte("R128_"+upper+"_GAIN=-4403")) ||
bytes.Contains(bytes.ToUpper(raw), []byte("REPLAYGAIN_"+upper+"_")) {
t.Fatalf("conflicting or incorrect %s comments", scope)
}
if scope == "track" && !bytes.Contains(raw, []byte("R128_ALBUM_GAIN=-2048")) {
t.Fatal("track update changed album gain")
}
}
after := readReplayGainOggPages(t, path)
if len(after) != len(pages) {
t.Fatalf("page count changed: %d -> %d", len(pages), len(after))
}
for i := range pages {
if i == 1 { // Only OpusTags may change.
continue
}
var beforePage, afterPage bytes.Buffer
_ = pages[i].serialize(&beforePage)
_ = after[i].serialize(&afterPage)
if !bytes.Equal(beforePage.Bytes(), afterPage.Bytes()) {
t.Fatalf("header/audio page %d changed", i)
}
}
raw := mustReadFile(t, path)
for _, comment := range []string{"CUSTOM=Keep me", "METADATA_BLOCK_PICTURE=existing-picture"} {
if !bytes.Contains(raw, []byte(comment)) {
t.Fatalf("lost %s", comment)
}
}
if err := EditOggFields(path, map[string]string{"replaygain_track_gain": ""}); err != nil {
t.Fatal(err)
}
raw = mustReadFile(t, path)
if bytes.Contains(raw, []byte("R128_TRACK_GAIN=")) || !bytes.Contains(raw, []byte("R128_ALBUM_GAIN=-4403")) {
t.Fatal("clearing track gain affected the wrong scope")
}
})
}
}
func TestOpusReplayGainInvalidValuesLeaveFileUntouched(t *testing.T) {
for _, gain := range []string{"invalid", "NaN", "+Inf", "-Inf", "-124 dB", "133 dB"} {
t.Run(gain, func(t *testing.T) {
path := filepath.Join(t.TempDir(), "song.opus")
buildTestOpus(t, path, []string{"R128_TRACK_GAIN=-1280"}, 1)
before := mustReadFile(t, path)
if err := EditOggFields(path, map[string]string{"replaygain_track_gain": gain}); err == nil {
t.Fatal("invalid gain was accepted")
}
if !bytes.Equal(before, mustReadFile(t, path)) {
t.Fatal("failed write changed the original")
}
})
}
for _, raw := range []string{"32768", "-32769", "1.5"} {
if _, ok := r128ToReplayGainDb(raw); ok {
t.Fatalf("invalid R128 value accepted: %s", raw)
}
}
}
func TestOpusReplayGainWithoutTitleOrArtist(t *testing.T) {
path := filepath.Join(t.TempDir(), "song.opus")
buildTestOpus(t, path, nil, 1)
if err := EditOggFields(path, map[string]string{"replaygain_track_gain": "0.00 dB"}); err != nil {
t.Fatal(err)
}
metadata, err := ReadFileMetadata(path)
if err != nil || !strings.Contains(metadata, `"replaygain_track_gain":"0.00 dB"`) {
t.Fatalf("ReplayGain-only comments were not read: %s, %v", metadata, err)
}
}
func TestOpusReplayGainMediaRoundTrip(t *testing.T) {
ffmpeg, err := exec.LookPath("ffmpeg")
if err != nil {
t.Skip("requires ffmpeg on PATH")
}
run := func(t *testing.T, args ...string) []byte {
t.Helper()
cmd := exec.Command(ffmpeg, append([]string{"-hide_banner", "-v", "error"}, args...)...)
var stderr bytes.Buffer
cmd.Stderr = &stderr
out, err := cmd.Output()
if err != nil {
t.Fatalf("ffmpeg: %v: %s", err, &stderr)
}
return out
}
for _, cover := range []bool{false, true} {
name := "without-cover"
if cover {
name = "with-cover"
}
t.Run(name, func(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "song.opus")
run(t, "-f", "lavfi", "-i", "sine=frequency=440:duration=0.2", "-c:a", "libopus",
"-metadata", "title=Example Song", "-metadata", "REPLAYGAIN_TRACK_GAIN=-4.00 dB",
"-metadata", "R128_TRACK_GAIN=-2304", path)
var picture []byte
if cover {
picture, err = base64.StdEncoding.DecodeString("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aB1sAAAAASUVORK5CYII=")
if err != nil {
t.Fatal(err)
}
coverPath := filepath.Join(dir, "cover.png")
if err := os.WriteFile(coverPath, picture, 0o600); err != nil {
t.Fatal(err)
}
if err := EditOggFields(path, map[string]string{"cover_path": coverPath}); err != nil {
t.Fatal(err)
}
}
audioHash := func() []byte {
return run(t, "-i", path, "-map", "0:a:0", "-c:a", "copy", "-f", "hash", "-hash", "sha256", "-")
}
beforeHash := audioHash()
if err := EditOggFields(path, map[string]string{
"replaygain_track_gain": "-12.20 dB", "replaygain_track_peak": "1.258925",
"replaygain_album_gain": "-10.00 dB", "replaygain_album_peak": "1.300000",
}); err != nil {
t.Fatal(err)
}
if !bytes.Equal(beforeHash, audioHash()) {
t.Fatal("encoded audio changed")
}
run(t, "-i", path, "-map", "0:a:0", "-f", "null", "-")
metadata, err := ReadOggVorbisComments(path)
if err != nil || metadata.ReplayGainTrackGain != "-12.20 dB" || metadata.ReplayGainAlbumGain != "-10.00 dB" {
t.Fatalf("gain reread: %+v, %v", metadata, err)
}
if cover {
got, _, err := extractOggCoverArt(path)
if err != nil || !bytes.Equal(got, picture) {
t.Fatalf("cover changed: %v", err)
}
}
})
}
}
@@ -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/platform_bridge.dart';
import 'package:spotiflac_android/services/download_request_payload.dart'; import 'package:spotiflac_android/services/download_request_payload.dart';
import 'package:spotiflac_android/services/ffmpeg_service.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/notification_service.dart';
import 'package:spotiflac_android/services/verification_notification.dart'; import 'package:spotiflac_android/services/verification_notification.dart';
import 'package:spotiflac_android/utils/logger.dart' hide log; import 'package:spotiflac_android/utils/logger.dart' hide log;
@@ -563,16 +563,16 @@ extension _DownloadQueueEmbedding on DownloadQueueNotifier {
final rgResult = await FFmpegService.scanReplayGain(filePath); final rgResult = await FFmpegService.scanReplayGain(filePath);
if (rgResult != null) { if (rgResult != null) {
scannedReplayGain = rgResult; scannedReplayGain = rgResult;
metadata['REPLAYGAIN_TRACK_GAIN'] = rgResult.trackGain; if (format != 'opus') {
metadata['REPLAYGAIN_TRACK_PEAK'] = rgResult.trackPeak; metadata['REPLAYGAIN_TRACK_GAIN'] = rgResult.trackGain;
if (format == 'opus') { metadata['REPLAYGAIN_TRACK_PEAK'] = rgResult.trackPeak;
final r128 = FFmpegService.replayGainDbToR128(rgResult.trackGain);
if (r128 != null) metadata['R128_TRACK_GAIN'] = r128;
} }
_log.d( _log.d(
'ReplayGain for $format: gain=${rgResult.trackGain}, peak=${rgResult.trackPeak}', 'ReplayGain for $format: gain=${rgResult.trackGain}, peak=${rgResult.trackPeak}',
); );
_storeTrackReplayGainForAlbum(track, filePath, rgResult); if (format != 'opus') {
_storeTrackReplayGainForAlbum(track, filePath, rgResult);
}
} }
} catch (e) { } catch (e) {
_log.w('Failed to scan ReplayGain for $format: $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, // audio through untouched — no FFmpeg spawn, no full container remux,
// no temp-promote copy. The Go side answers method=ffmpeg for files it // 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. // can't handle natively, and any failure falls back to FFmpeg below.
// Scanned ReplayGain (opt-in, non-FLAC) keeps the FFmpeg path: its // Opus ReplayGain is written and verified separately below, through the
// extra tags (e.g. Opus R128_TRACK_GAIN) ride the FFmpeg metadata map. // same native R128 writer used by manual scans and album gain updates.
var embeddedNatively = false; var embeddedNatively = false;
if (scannedReplayGain == null) { if (scannedReplayGain == null || format == 'opus') {
try { try {
final nativeFields = <String, String>{ final nativeFields = <String, String>{
'title': track.name, 'title': track.name,
@@ -686,7 +686,10 @@ extension _DownloadQueueEmbedding on DownloadQueueNotifier {
filePath, filePath,
nativeFields, nativeFields,
); );
embeddedNatively = response['method'] != 'ffmpeg'; embeddedNatively =
response['success'] == true &&
response['error'] == null &&
response['method'] != 'ffmpeg';
} catch (e) { } catch (e) {
_log.w('Native $format tag embed failed, falling back to FFmpeg: $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) { if (isM4a && settings.embedReplayGain && scannedReplayGain != null) {
try { try {
await PlatformBridge.editFileMetadata(filePath, { await PlatformBridge.editFileMetadata(filePath, {
@@ -1415,7 +1415,6 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier {
actualBitrate = autoConvertBitrateKbps(settings.autoConvertBitrate); actualBitrate = autoConvertBitrateKbps(settings.autoConvertBitrate);
} }
await _writeNativeWorkerReplayGain( await _writeNativeWorkerReplayGain(
context: context,
settings: settings, settings: settings,
track: trackToDownload, track: trackToDownload,
filePath: filePath, filePath: filePath,
@@ -1547,7 +1546,6 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier {
} }
Future<void> _writeNativeWorkerReplayGain({ Future<void> _writeNativeWorkerReplayGain({
required _NativeWorkerRequestContext context,
required AppSettings settings, required AppSettings settings,
required Track track, required Track track,
required String filePath, required String filePath,
@@ -1555,19 +1553,20 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier {
if (!settings.embedReplayGain) { if (!settings.embedReplayGain) {
return; 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; return;
} }
try { try {
final rgResult = await FFmpegService.scanReplayGain(filePath); final rgResult = await ReplayGainService.scanAndApplyToFile(filePath);
if (rgResult == null) { if (rgResult == null) {
return; return;
} }
await PlatformBridge.editFileMetadata(filePath, {
'replaygain_track_gain': rgResult.trackGain,
'replaygain_track_peak': rgResult.trackPeak,
});
_storeTrackReplayGainForAlbum(track, filePath, rgResult); _storeTrackReplayGainForAlbum(track, filePath, rgResult);
_updateAlbumRgFilePath(track, filePath); _updateAlbumRgFilePath(track, filePath);
await _checkAndWriteAlbumReplayGain(track); await _checkAndWriteAlbumReplayGain(track);
@@ -172,55 +172,13 @@ extension _DownloadQueueReplayGain on DownloadQueueNotifier {
String albumGain, String albumGain,
String albumPeak, String albumPeak,
) async { ) async {
final lower = filePath.toLowerCase(); final ok = await ReplayGainService.writeAlbumTags(
if (lower.endsWith('.flac') || filePath,
lower.endsWith('.ape') || albumGain,
lower.endsWith('.wv') || albumPeak,
lower.endsWith('.mpc')) { );
// Native writer — only touches the provided fields, preserves the rest. if (!ok) {
await PlatformBridge.editFileMetadata(filePath, { _log.w('Album ReplayGain write failed for: $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');
}
} }
} }
+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. /// Write album ReplayGain tags to a file via FFmpeg.
/// ///
/// For local files, replaces the file in-place and returns `true`. /// 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. /// 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 /// Used as a fallback for formats other than Ogg/Opus. Copies streams and
/// (MP3/Opus). All existing streams and metadata are preserved via /// metadata with `-map 0 -c copy -map_metadata 0`, setting track gain and peak.
/// `-map 0 -c copy -map_metadata 0`; only the REPLAYGAIN_TRACK_* fields are /// The caller verifies the tags after a successful rewrite.
/// added/overwritten. Returns `true` when the file was rewritten in place.
static Future<bool> writeTrackReplayGainTags( static Future<bool> writeTrackReplayGainTags(
String filePath, String filePath,
String trackGain, String trackGain,
@@ -1108,7 +1091,7 @@ class FFmpegService {
) => _writeReplayGainTags(filePath, 'Track', trackGain, trackPeak); ) => _writeReplayGainTags(filePath, 'Track', trackGain, trackPeak);
/// Shared implementation for album/track ReplayGain tagging. /// 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( static Future<bool> _writeReplayGainTags(
String filePath, String filePath,
String scope, String scope,
@@ -1120,6 +1103,10 @@ class FFmpegService {
final ext = filePath.contains('.') final ext = filePath.contains('.')
? '.${filePath.split('.').last}' ? '.${filePath.split('.').last}'
: '.tmp'; : '.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 tempDir = await getTemporaryDirectory();
final tempOutput = _nextTempEmbedPath(tempDir.path, ext); final tempOutput = _nextTempEmbedPath(tempDir.path, ext);
final tag = scope.toUpperCase(); final tag = scope.toUpperCase();
@@ -1141,15 +1128,6 @@ class FFmpegService {
'REPLAYGAIN_${tag}_PEAK=$peak', 'REPLAYGAIN_${tag}_PEAK=$peak',
]; ];
if (ext.toLowerCase() == '.opus') {
final r128 = replayGainDbToR128(gain);
if (r128 != null) {
arguments
..add('-metadata')
..add('R128_${tag}_GAIN=$r128');
}
}
arguments arguments
..add(tempOutput) ..add(tempOutput)
..add('-y'); ..add('-y');
@@ -1157,6 +1135,11 @@ class FFmpegService {
_log.d('Writing ${scope.toLowerCase()} ReplayGain tags via FFmpeg'); _log.d('Writing ${scope.toLowerCase()} ReplayGain tags via FFmpeg');
final result = await _executeWithArguments(arguments); final result = await _executeWithArguments(arguments);
if (!result.success) {
_log.e(
'$scope ReplayGain write failed (code ${result.returnCode}): ${result.output}',
);
}
if (result.success) { if (result.success) {
if (returnTempPath) { if (returnTempPath) {
try { try {
+30 -1
View File
@@ -37,6 +37,12 @@ void setPlaybackNormalizationEnabled(bool enabled) {
_activeMusicPlayerHandler?.reapplyNormalization(); _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({ List<int> buildShuffleCandidatePool({
required int mediaCount, required int mediaCount,
required int currentIndex, required int currentIndex,
@@ -556,6 +562,24 @@ class MusicPlayerHandler extends BaseAudioHandler
onReadError: (error) => onReadError: (error) =>
_log.w('Failed to read gain tags for normalization: $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( Future<double> _normalizationVolumeFor(
String path, { String path, {
@@ -574,6 +598,7 @@ class MusicPlayerHandler extends BaseAudioHandler
void reapplyNormalization() { void reapplyNormalization() {
final index = _index; final index = _index;
final generation = _playRequestGeneration; final generation = _playRequestGeneration;
final normalizationGeneration = ++_normalizationGeneration;
if (index < 0 || index >= _media.length) return; if (index < 0 || index >= _media.length) return;
unawaited(() async { unawaited(() async {
final media = _media[index]; final media = _media[index];
@@ -599,7 +624,11 @@ class MusicPlayerHandler extends BaseAudioHandler
if (playbackLease != null) { if (playbackLease != null) {
await PlatformBridge.closeContentUriPlaybackLease(playbackLease.token); await PlatformBridge.closeContentUriPlaybackLease(playbackLease.token);
} }
if (_index != index || generation != _playRequestGeneration) return; if (_index != index ||
generation != _playRequestGeneration ||
normalizationGeneration != _normalizationGeneration) {
return;
}
try { try {
await _player.setVolume(volume); await _player.setVolume(volume);
} catch (e) { } catch (e) {
+11 -2
View File
@@ -7,10 +7,16 @@ class PlaybackNormalizationCache {
readMetadata; readMetadata;
final void Function(Object)? onReadError; final void Function(Object)? onReadError;
final Map<String, double> _volumes = {}; final Map<String, double> _volumes = {};
int _generation = 0;
static final _gainNumber = RegExp(r'-?\d+(\.\d+)?'); static final _gainNumber = RegExp(r'-?\d+(\.\d+)?');
PlaybackNormalizationCache({required this.readMetadata, this.onReadError}); PlaybackNormalizationCache({required this.readMetadata, this.onReadError});
void invalidate(String source) {
_volumes.remove(source);
_generation++;
}
Future<double> volumeFor( Future<double> volumeFor(
String path, { String path, {
String? cacheKey, String? cacheKey,
@@ -19,6 +25,7 @@ class PlaybackNormalizationCache {
final key = cacheKey ?? path; final key = cacheKey ?? path;
final cached = _volumes[key]; final cached = _volumes[key];
if (cached != null) return cached; if (cached != null) return cached;
final generation = _generation;
try { try {
final metadata = await readMetadata(path, displayName: displayName); final metadata = await readMetadata(path, displayName: displayName);
if (metadata['error'] != null) { if (metadata['error'] != null) {
@@ -31,8 +38,10 @@ class PlaybackNormalizationCache {
final volume = gain == null final volume = gain == null
? 1.0 ? 1.0
: pow(10.0, gain / 20.0).toDouble().clamp(0.0, 1.0); : pow(10.0, gain / 20.0).toDouble().clamp(0.0, 1.0);
if (_volumes.length >= 128) _volumes.remove(_volumes.keys.first); if (generation == _generation) {
_volumes[key] = volume; if (_volumes.length >= 128) _volumes.remove(_volumes.keys.first);
_volumes[key] = volume;
}
return volume; return volume;
} catch (error) { } catch (error) {
onReadError?.call(error); onReadError?.call(error);
+125 -31
View File
@@ -1,16 +1,16 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:spotiflac_android/services/ffmpeg_service.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/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/utils/logger.dart';
/// Standalone ReplayGain (re)scanning for existing audio files. /// Standalone ReplayGain (re)scanning for existing audio files.
/// ///
/// Computes EBU R128 loudness via FFmpeg and writes REPLAYGAIN_TRACK_* tags /// Computes EBU R128 loudness via FFmpeg and writes gain tags using the native
/// back into the file in place: /// metadata editors where supported. Opus uses R128_* rather than legacy tags.
/// - FLAC / M4A / MP4 / APE / WV / MPC -> native tag writer (PlatformBridge)
/// - MP3 / Opus / OGG / others -> FFmpeg copy-with-metadata
/// ///
/// Handles SAF content:// URIs transparently by working on a temporary copy /// Handles SAF content:// URIs transparently by working on a temporary copy
/// and writing it back to the original document. /// and writing it back to the original document.
@@ -31,6 +31,9 @@ class ReplayGainService {
'.aiff', '.aiff',
'.aif', '.aif',
'.aifc', '.aifc',
'.mp3',
'.opus',
'.ogg',
}; };
static bool _isNativeWritableFormat(String path) { static bool _isNativeWritableFormat(String path) {
@@ -42,7 +45,120 @@ class ReplayGainService {
/// ///
/// Returns `true` when tags were successfully written, `false` otherwise /// Returns `true` when tags were successfully written, `false` otherwise
/// (scan failed, write failed, or SAF write-back failed). /// (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; if (filePath.isEmpty) return false;
final isSaf = isContentUri(filePath); final isSaf = isContentUri(filePath);
@@ -59,40 +175,18 @@ class ReplayGainService {
workingPath = safTempPath; workingPath = safTempPath;
} }
final rg = await FFmpegService.scanReplayGain(workingPath); if (!await update(workingPath)) return false;
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 (isSaf) { if (isSaf) {
final ok = await PlatformBridge.writeTempToSaf(workingPath, filePath); final ok = await PlatformBridge.writeTempToSaf(workingPath, filePath);
if (!ok) { if (!ok) {
_log.w('Failed to write ReplayGain temp file back to SAF document'); _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; return true;
} catch (e) { } catch (e) {
_log.e('Failed to apply ReplayGain', e); _log.e('Failed to apply ReplayGain', e);
+35
View File
@@ -1,7 +1,42 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/services/playback_normalization.dart'; import 'package:spotiflac_android/services/playback_normalization.dart';
void main() { 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( test(
'descriptor normalization uses hint and caches by original URI', 'descriptor normalization uses hint and caches by original URI',
() async { () 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']);
});
}