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
}