feat(api): deliver loudness gains to every client (M464 #4997)
release / web (push) Successful in 1m44s
release / go (push) Successful in 2m12s
release / govulncheck (push) Successful in 40s
release / android (push) Successful in 5m28s
release / Build signed APK (releases and dev) (push) Successful in 4m42s
release / integration (push) Successful in 15m33s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m21s
release / Verify release artifacts (tag releases only) (push) Skipped

ReplayGain 2.0 values (gain to -18 LUFS, linear peak) derived from the
stored track and album loudness:

- Web: GET /api/tracks/replay-gain?ids=... (up to 200), a lookup the
  player calls for its queue, rather than a field on every TrackRef
  surface.
- Android: track_gain/track_peak and album_gain/album_peak on the sync
  views, so cached tracks level offline. Storing a measurement logs a
  track change, and an album's values moving logs an album change, both
  before the write (#2704), so caches pick the gains up.
- OpenSubsonic: replayGain on every song (album, getSong, search3,
  starred), as a JSON object and an XML element.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 13:54:05 -04:00
co-authored by Claude Opus 5.5
parent c2f81bf8df
commit e3aa8629d3
19 changed files with 797 additions and 38 deletions
+122
View File
@@ -0,0 +1,122 @@
package library
import (
"context"
"math"
"path/filepath"
"testing"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
func TestGainDBAndLinearPeak(t *testing.T) {
f := func(v float32) *float32 { return &v }
for _, c := range []struct {
lufs, want float32
}{
{-18, 0}, // at the reference: no change
{-9.5, -8.5}, // a loud master is turned down
{-23.456, 5.46}, // a quiet one up, to 0.01 dB
} {
if got := GainDB(f(c.lufs)); got == nil || *got != c.want {
t.Errorf("GainDB(%v) = %v, want %v", c.lufs, got, c.want)
}
}
if GainDB(nil) != nil {
t.Errorf("GainDB(nil) is not nil")
}
for _, c := range []struct {
dbtp, want float32
}{
{0, 1}, // full scale
{-6.0206, 0.5}, // half amplitude
{1.2, 1.1482}, // an inter-sample over, above 1.0
} {
got := LinearPeak(f(c.dbtp))
if got == nil || math.Abs(float64(*got-c.want)) > 0.0001 {
t.Errorf("LinearPeak(%v) = %v, want %v", c.dbtp, got, c.want)
}
}
if LinearPeak(nil) != nil {
t.Errorf("LinearPeak(nil) is not nil")
}
}
func TestReplayGainForTracks_Integration(t *testing.T) {
pool := newPool(t)
ctx := context.Background()
q := dbq.New(pool)
dir := t.TempDir()
measured, album, artist := seedTrack(t, pool, filepath.Join(dir, "measured.mp3"))
add := func(name string) dbq.Track {
t.Helper()
tr, err := q.UpsertTrack(ctx, dbq.UpsertTrackParams{
Title: name, AlbumID: album.ID, ArtistID: artist.ID,
DurationMs: 1000, FilePath: filepath.Join(dir, name+".mp3"), FileSize: 100, FileFormat: "mp3",
})
if err != nil {
t.Fatalf("track %s: %v", name, err)
}
return tr
}
older := add("older")
unmeasured := add("unmeasured")
f := func(v float32) *float32 { return &v }
for _, m := range []struct {
tr dbq.Track
lufs float32
version int16
}{
{measured, -12, loudnessVersion},
// A measurement by an older method is still a better gain than none.
{older, -20, loudnessVersion - 1},
} {
if err := q.UpsertTrackLoudness(ctx, dbq.UpsertTrackLoudnessParams{
TrackID: m.tr.ID, IntegratedLufs: f(m.lufs), TruePeakDbtp: f(-1), AnalysisVersion: m.version,
}); err != nil {
t.Fatalf("seed: %v", err)
}
}
gains, err := ReplayGainForTracks(ctx, q, []pgtype.UUID{measured.ID, older.ID, unmeasured.ID})
if err != nil {
t.Fatalf("lookup: %v", err)
}
if g := gains[measured.ID]; g.TrackGain == nil || *g.TrackGain != -6 || g.TrackPeak == nil {
t.Errorf("measured track gains = %+v, want track gain -6 with a peak", g)
}
if g := gains[older.ID]; g.TrackGain == nil || *g.TrackGain != 2 {
t.Errorf("older-version track gains = %+v, want track gain 2", g)
}
if _, ok := gains[unmeasured.ID]; ok {
t.Errorf("unmeasured track has an entry; absent means no adjustment")
}
// No album value yet (none computed): the album pair is absent.
if g := gains[measured.ID]; g.AlbumGain != nil || g.AlbumPeak != nil {
t.Errorf("album pair present before album loudness exists: %+v", g)
}
if err := q.UpsertAlbumLoudness(ctx, dbq.UpsertAlbumLoudnessParams{
AlbumID: album.ID, IntegratedLufs: f(-14), TruePeakDbtp: f(-0.5),
TracksTotal: 3, TracksSettled: 3, InputsDigest: "x",
}); err != nil {
t.Fatalf("seed album: %v", err)
}
gains, err = ReplayGainForTracks(ctx, q, []pgtype.UUID{unmeasured.ID})
if err != nil {
t.Fatalf("lookup: %v", err)
}
if g := gains[unmeasured.ID]; g.TrackGain != nil || g.AlbumGain == nil || *g.AlbumGain != -4 || g.AlbumPeak == nil {
t.Errorf("unmeasured track on a leveled album = %+v, want only the album pair (gain -4)", g)
}
byAlbum, err := ReplayGainForAlbums(ctx, q, []pgtype.UUID{album.ID})
if err != nil {
t.Fatalf("album lookup: %v", err)
}
if g := byAlbum[album.ID]; g.AlbumGain == nil || *g.AlbumGain != -4 || g.TrackGain != nil {
t.Errorf("album gains = %+v, want album gain -4 and no track pair", g)
}
}