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
+54 -4
View File
@@ -2,11 +2,14 @@ package library
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
)
// Album loudness (M464 #4996).
@@ -117,24 +120,46 @@ func (w *LoudnessBackfillWorker) albumPass(ctx context.Context) (AlbumLoudnessRe
}
for _, row := range rows {
res.Recomputed++
if err := storeAlbumLoudness(ctx, q, row.AlbumID, row.Digest, &res); err != nil {
if err := w.storeAlbumLoudness(ctx, q, row.AlbumID, row.Digest, &res); err != nil {
res.Failed++
w.logger.Warn("album loudness: recompute failed", "album_id", row.AlbumID, "err", err)
}
}
after = rows[len(rows)-1].AlbumID
}
n, err := q.DeleteOrphanAlbumLoudness(ctx)
// Listed, logged, then deleted: the same log-first order as above.
orphans, err := q.ListOrphanAlbumLoudness(ctx)
if err != nil {
return res, fmt.Errorf("list orphan album loudness: %w", err)
}
if len(orphans) == 0 {
return res, nil
}
ids := make([]string, len(orphans))
for i, id := range orphans {
ids[i] = syncpkg.FormatUUID(id)
}
if err := syncpkg.LogChanges(ctx, w.pool, syncpkg.EntityAlbum, ids, syncpkg.OpUpsert); err != nil {
return res, fmt.Errorf("log orphan album changes: %w", err)
}
if err := q.DeleteAlbumLoudness(ctx, orphans); err != nil {
return res, fmt.Errorf("drop orphan album loudness: %w", err)
}
res.Orphans = n
res.Orphans = int64(len(orphans))
return res, nil
}
func storeAlbumLoudness(
// storeAlbumLoudness recomputes one album. The sync feed hears about it only
// when the values clients see move: during the backfill an album's digest
// changes with every track measured, and most of those recomputes still end
// with no album value.
func (w *LoudnessBackfillWorker) storeAlbumLoudness(
ctx context.Context, q *dbq.Queries, albumID pgtype.UUID, digest string, res *AlbumLoudnessResult,
) error {
before, err := q.GetAlbumLoudness(ctx, albumID)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return fmt.Errorf("read stored values: %w", err)
}
inputs, err := q.ListAlbumLoudnessInputs(ctx, dbq.ListAlbumLoudnessInputsParams{
CurrentVersion: loudnessVersion,
AlbumID: albumID,
@@ -143,6 +168,14 @@ func storeAlbumLoudness(
return fmt.Errorf("read inputs: %w", err)
}
a := computeAlbumLoudness(inputs)
// Logged before the write, for the reason storeLoudness gives (#2704). A
// failed log stores nothing, so the digest still differs and the next
// pass tries again.
if albumVisibleChange(before, a) {
if err := syncpkg.LogChange(ctx, w.pool, syncpkg.EntityAlbum, syncpkg.FormatUUID(albumID), syncpkg.OpUpsert); err != nil {
return fmt.Errorf("log album change: %w", err)
}
}
if err := q.UpsertAlbumLoudness(ctx, dbq.UpsertAlbumLoudnessParams{
AlbumID: albumID,
IntegratedLufs: a.integratedLUFS,
@@ -160,3 +193,20 @@ func storeAlbumLoudness(
}
return nil
}
// albumVisibleChange reports whether clients would see different album gains.
// The peak is only delivered beside a loudness, so a waiting album whose peak
// moves as tracks are measured has nothing new to tell anyone.
func albumVisibleChange(before dbq.GetAlbumLoudnessRow, after albumLoudness) bool {
if !sameFloat(before.IntegratedLufs, after.integratedLUFS) {
return true
}
return after.integratedLUFS != nil && !sameFloat(before.TruePeakDbtp, after.truePeakDBTP)
}
func sameFloat(a, b *float32) bool {
if a == nil || b == nil {
return a == b
}
return *a == *b
}
+31 -1
View File
@@ -9,6 +9,7 @@ import (
"testing"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
)
// histAt builds a histogram of n blocks all at one loudness.
@@ -162,6 +163,17 @@ func TestAlbumLoudness_Integration(t *testing.T) {
return res
}
albumChanges := func() int {
t.Helper()
var n int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM library_changes
WHERE entity_type = 'album' AND entity_id = $1`,
syncpkg.FormatUUID(album.ID)).Scan(&n); err != nil {
t.Fatalf("count album changes: %v", err)
}
return n
}
second := addTrack("b", album)
measure(first, -10, -1)
@@ -173,6 +185,11 @@ func TestAlbumLoudness_Integration(t *testing.T) {
t.Fatalf("waiting album = lufs %v, %d/%d settled; want nil, 1/2", lufs, settled, total)
}
// Still no album value: clients have nothing new to read (#4997).
if n := albumChanges(); n != 0 {
t.Fatalf("a waiting album logged %d sync changes, want 0", n)
}
// 2. Measuring the second track changes the digest; the album is leveled.
measure(second, -10, -0.5)
if res := pass(); res.Recomputed != 1 || res.Leveled != 1 {
@@ -183,10 +200,18 @@ func TestAlbumLoudness_Integration(t *testing.T) {
t.Fatalf("leveled album = lufs %v peak %v, want -10 and -0.5", lufs, peak)
}
// 3. Nothing changed: nothing recomputed.
// Leveled: clients are told, once.
if n := albumChanges(); n != 1 {
t.Fatalf("leveling the album logged %d sync changes, want 1", n)
}
// 3. Nothing changed: nothing recomputed, nothing logged.
if res := pass(); res.Recomputed != 0 {
t.Fatalf("idle pass = %+v, want nothing recomputed", res)
}
if n := albumChanges(); n != 1 {
t.Fatalf("an idle pass logged album changes (now %d), want still 1", n)
}
// 4. A track joins (here, retagged onto this album): recomputed.
other, err := q.UpsertAlbum(ctx, dbq.UpsertAlbumParams{Title: "Other", SortTitle: "Other", ArtistID: artist.ID})
@@ -232,9 +257,14 @@ func TestAlbumLoudness_Integration(t *testing.T) {
if _, err := pool.Exec(ctx, "UPDATE tracks SET missing_since = now() WHERE id = $1", first.ID); err != nil {
t.Fatalf("mark missing: %v", err)
}
before := albumChanges()
if res := pass(); res.Orphans != 1 {
t.Fatalf("pass with every track missing = %+v, want 1 orphan dropped", res)
}
// Dropping the row takes the album gain away, so clients are told.
if n := albumChanges(); n != before+1 {
t.Fatalf("dropping the orphan logged %d album changes, want 1", n-before)
}
var n int
if err := pool.QueryRow(ctx, "SELECT count(*) FROM album_loudness WHERE album_id = $1", album.ID).Scan(&n); err != nil {
t.Fatalf("count: %v", err)
+15 -1
View File
@@ -17,6 +17,7 @@ import (
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
)
// Loudness analysis (M464 #4995).
@@ -351,10 +352,15 @@ const (
// An inconclusive attempt leaves any existing row alone. The scan deletes a
// row when its file changes, so a row still here describes these bytes, and a
// value from an older method is a better gain than none until it is redone.
//
// A stored measurement is logged to the sync feed as a track upsert, so
// clients that cache the library (Android) pick up the new gain (#4997). If
// the log fails nothing is stored, and the backfill tries the track again.
func storeLoudness(
ctx context.Context, q *dbq.Queries, logger *slog.Logger,
ctx context.Context, db dbq.DBTX, logger *slog.Logger,
trackID pgtype.UUID, path string, r loudnessResult,
) loudnessOutcome {
q := dbq.New(db)
if r.err != nil {
logger.Warn("loudness: analysis failed", "path", path, "err", r.err)
if r.inconclusive() {
@@ -388,6 +394,14 @@ func storeLoudness(
}
}
}
// Logged before the write, as reconcile does (#2704): a log that then
// finds the write failed costs clients one wasted re-read, but a write
// that lands with no log is never retried, since the track is settled, and
// clients would never learn its gain.
if err := syncpkg.LogChange(ctx, db, syncpkg.EntityTrack, syncpkg.FormatUUID(trackID), syncpkg.OpUpsert); err != nil {
logger.Warn("loudness: LogChange track upsert failed; not storing", "path", path, "err", err)
return loudnessStoreFailed
}
if err := q.UpsertTrackLoudness(ctx, params); err != nil {
logger.Warn("loudness: storing measurement failed", "path", path, "err", err)
return loudnessStoreFailed
+1 -1
View File
@@ -187,7 +187,7 @@ func (w *LoudnessBackfillWorker) pass(ctx context.Context) (BackfillLoudnessResu
w.logger.Error("loudness backfill: track panicked", "path", row.FilePath, "panic", r)
}
}()
outcome := storeLoudness(ctx, q, w.logger, row.ID, row.FilePath,
outcome := storeLoudness(ctx, w.pool, w.logger, row.ID, row.FilePath,
w.analyzeFile(ctx, row.FilePath, row.DurationMs))
mu.Lock()
res.add(outcome)
@@ -11,6 +11,7 @@ import (
"testing"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
)
// TestLoudnessBackfill_Integration pins which tracks a pass measures, what it
@@ -130,6 +131,18 @@ func TestLoudnessBackfill_Integration(t *testing.T) {
gotLUFS, gotPeak, gotHist, gotStart, gotVersion)
}
// Each stored measurement told the sync feed, so caching clients re-read
// the track and pick up its gain (#4997).
var logged int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM library_changes
WHERE entity_type = 'track' AND op = 'upsert' AND entity_id = $1`,
syncpkg.FormatUUID(stale.ID)).Scan(&logged); err != nil {
t.Fatalf("count sync changes: %v", err)
}
if logged != 1 {
t.Errorf("re-measuring stale.mp3 logged %d track changes, want 1", logged)
}
// 2. A pass after a complete one is a no-op.
res, err = w.pass(ctx)
if err != nil {
+102
View File
@@ -0,0 +1,102 @@
package library
import (
"context"
"math"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// ReplayGain delivery (M464 #4997).
//
// Clients are handed loudness in the ReplayGain 2.0 form every player already
// understands: a gain in dB that brings the track (or album) to the RG2
// reference of -18 LUFS, and a linear peak. A client aiming at another target
// adds (target - ReplayGainReferenceLUFS) to the gain. The same numbers go to
// the native API, the sync feed and OpenSubsonic's replayGain, so every client
// levels a track identically.
// ReplayGainReferenceLUFS is the RG2 reference loudness gains are relative to.
const ReplayGainReferenceLUFS = -18.0
// ReplayGain is one track's gains. Each field is nil when there is nothing to
// say: not yet measured, silent, or (for the album pair) an album still
// waiting on a track. AlbumPeak travels only with AlbumGain.
type ReplayGain struct {
TrackGain *float32 `json:"track_gain,omitempty"`
TrackPeak *float32 `json:"track_peak,omitempty"`
AlbumGain *float32 `json:"album_gain,omitempty"`
AlbumPeak *float32 `json:"album_peak,omitempty"`
}
// Empty reports whether no gain is known at all.
func (g ReplayGain) Empty() bool {
return g.TrackGain == nil && g.AlbumGain == nil
}
// GainDB converts an integrated loudness to its RG2 gain, to 0.01 dB.
func GainDB(lufs *float32) *float32 {
if lufs == nil {
return nil
}
v := float32(math.Round((ReplayGainReferenceLUFS-float64(*lufs))*100) / 100)
return &v
}
// LinearPeak converts a true peak in dBTP to the linear amplitude ReplayGain
// peaks are written in (1.0 is full scale), to 4 decimals.
func LinearPeak(dbtp *float32) *float32 {
if dbtp == nil {
return nil
}
v := float32(math.Round(math.Pow(10, float64(*dbtp)/20)*10000) / 10000)
return &v
}
// ReplayGainForTracks returns the gains for each of ids that has any. Tracks
// with none are absent from the map, so a lookup of a missing key yields the
// zero ReplayGain: no adjustment.
func ReplayGainForTracks(ctx context.Context, q *dbq.Queries, ids []pgtype.UUID) (map[pgtype.UUID]ReplayGain, error) {
out := make(map[pgtype.UUID]ReplayGain, len(ids))
if len(ids) == 0 {
return out, nil
}
rows, err := q.GetReplayGainByTrackIDs(ctx, ids)
if err != nil {
return nil, err
}
for _, r := range rows {
g := ReplayGain{TrackGain: GainDB(r.TrackLufs)}
if g.TrackGain != nil {
g.TrackPeak = LinearPeak(r.TrackPeakDbtp)
}
if g.AlbumGain = GainDB(r.AlbumLufs); g.AlbumGain != nil {
g.AlbumPeak = LinearPeak(r.AlbumPeakDbtp)
}
if !g.Empty() {
out[r.ID] = g
}
}
return out, nil
}
// ReplayGainForAlbums returns the album pair for each of albumIDs that has an
// album loudness. The track fields are left nil.
func ReplayGainForAlbums(ctx context.Context, q *dbq.Queries, albumIDs []pgtype.UUID) (map[pgtype.UUID]ReplayGain, error) {
out := make(map[pgtype.UUID]ReplayGain, len(albumIDs))
if len(albumIDs) == 0 {
return out, nil
}
rows, err := q.GetAlbumLoudnessByIDs(ctx, albumIDs)
if err != nil {
return nil, err
}
for _, r := range rows {
if g := GainDB(r.IntegratedLufs); g != nil {
out[r.AlbumID] = ReplayGain{AlbumGain: g, AlbumPeak: LinearPeak(r.TruePeakDbtp)}
}
}
return out, nil
}
+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)
}
}