mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-19 16:22:14 +02:00
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:
@@ -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")
|
||||
}
|
||||
|
||||
@@ -314,9 +315,9 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
|
||||
|
||||
// r128ToReplayGainDb converts an R128_*_GAIN value (integer, 1/256 dB steps,
|
||||
// -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) {
|
||||
q, err := strconv.Atoi(strings.TrimSpace(raw))
|
||||
q, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 16)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
|
||||
@@ -7,8 +7,10 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
flacvorbis "github.com/go-flac/flacvorbis/v2"
|
||||
@@ -350,6 +352,12 @@ func EditOggFields(filePath string, fields map[string]string) error {
|
||||
cmt.Vendor = vendor
|
||||
cmt.Comments = comments
|
||||
applyVorbisFieldEdits(cmt, fields)
|
||||
if isOpus {
|
||||
if err := applyOpusReplayGainEdits(cmt, fields); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
coverPath := strings.TrimSpace(fields["cover_path"])
|
||||
if coverPath != "" && fileExists(coverPath) {
|
||||
@@ -446,3 +454,30 @@ func EditOggFields(filePath string, fields map[string]string) error {
|
||||
syncDir(filepath.Dir(filePath))
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user