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
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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user