Files
minstrel/internal/library/loudness_settings.go
T
bvandeusenandClaude Opus 5.5 f34423a0e0
release / go (push) Successful in 2m17s
release / govulncheck (push) Successful in 26s
release / web (push) Successful in 2m4s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / integration (push) Canceled after 4m39s
release / android (push) Canceled after 2m53s
release / Build signed APK (releases and dev) (push) Canceled after 2m24s
feat: leveled FLAC stream for Sonos/UPnP speakers (M464 #5001)
Speakers fetch their own audio, so the phone cannot level it. A cast
token minted with level=true (and the client's asAlbum, which only the
queue holder knows) now returns GET /api/tracks/{id}/leveled.flac: the
track rendered by ffmpeg at the user's gain (volume=XdB, plus
alimiter at -1 dBFS for a limiter-mode boost), metadata stripped, FLAC
at 16 or 24 bits and at most 48 kHz. The gain is computed server-side
from the user's preference and the stored loudness, carried as
?g=<centi-dB>&lim=0|1 and signed into the token, so an edited URL does
not verify. Unity gains get the plain stream.

Renders are written beside the cache file and renamed in, keyed by the
source's size and mtime, coalesced per file (singleflight, detached
from the requesting speaker so a retry finds the render running),
started at mint time so the fetch finds them ready, and evicted least
recently used past leveled_cache_mb, a new admin setting (migration
0068, Loudness analysis card).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 19:31:07 -04:00

123 lines
4.0 KiB
Go

package library
import (
"context"
"errors"
"fmt"
"sync"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// Loudness analysis settings (M464 #4995). Rule 25: an operator setting is a
// database row, changed without a restart. One instance is shared by the
// backfill and the admin API, so a save reaches the worker at once.
// LoudnessSettings mirrors the loudness_settings row.
type LoudnessSettings struct {
// Enabled off stops the background analysis. Measured tracks keep their
// values, so normalization keeps working for them.
Enabled bool
BackfillConcurrency int32
// LeveledCacheMB caps the disk the leveled-stream renders for Sonos and
// UPnP speakers may use (#5001); the least recently played go first.
LeveledCacheMB int32
// UpdatedAt is set by the database; ignored by Set.
UpdatedAt time.Time
}
// DefaultLoudnessSettings mirrors migrations 0065 and 0068's column defaults, so a
// database that cannot be read still analyzes the way a fresh install does.
var DefaultLoudnessSettings = LoudnessSettings{
Enabled: true,
BackfillConcurrency: loudnessBackfillConcurrency,
LeveledCacheMB: 2048,
}
// The leveled-stream cache's bounds, as migration 0068's CHECK has them.
const (
minLeveledCacheMB = 256
maxLeveledCacheMB = 65536
)
// ErrLoudnessSettingOutOfRange is returned by Set for a value migration 0065's
// CHECK would reject, so the API answers 400 naming the field.
var ErrLoudnessSettingOutOfRange = errors.New("loudness setting out of range")
// LoudnessSettingsService caches the settings and owns their persistence.
type LoudnessSettingsService struct {
pool *pgxpool.Pool
mu sync.RWMutex
cur LoudnessSettings
}
// NewLoudnessSettingsService loads once and caches. It always returns a usable
// service, holding the defaults when the load fails; the error says so.
func NewLoudnessSettingsService(ctx context.Context, pool *pgxpool.Pool) (*LoudnessSettingsService, error) {
s := &LoudnessSettingsService{pool: pool, cur: DefaultLoudnessSettings}
row, err := dbq.New(pool).GetLoudnessSettings(ctx)
if err != nil {
return s, fmt.Errorf("loudness settings: load: %w", err)
}
s.cur = loudnessSettingsFromRow(row)
return s, nil
}
// Get returns the cached settings. A nil service answers with the defaults.
func (s *LoudnessSettingsService) Get() LoudnessSettings {
if s == nil {
return DefaultLoudnessSettings
}
s.mu.RLock()
defer s.mu.RUnlock()
return s.cur
}
// Set validates, persists and re-caches.
func (s *LoudnessSettingsService) Set(ctx context.Context, in LoudnessSettings) (LoudnessSettings, error) {
if err := validateLoudnessSettings(in); err != nil {
return LoudnessSettings{}, err
}
if s == nil {
return LoudnessSettings{}, errors.New("loudness settings: no settings service")
}
row, err := dbq.New(s.pool).UpdateLoudnessSettings(ctx, dbq.UpdateLoudnessSettingsParams{
Enabled: in.Enabled,
BackfillConcurrency: in.BackfillConcurrency,
LeveledCacheMb: in.LeveledCacheMB,
})
if err != nil {
return LoudnessSettings{}, fmt.Errorf("loudness settings: save: %w", err)
}
out := loudnessSettingsFromRow(row)
s.mu.Lock()
s.cur = out
s.mu.Unlock()
return out, nil
}
func validateLoudnessSettings(in LoudnessSettings) error {
if in.BackfillConcurrency < minBackfillConcurrency || in.BackfillConcurrency > maxBackfillConcurrency {
return fmt.Errorf("%w: backfill_concurrency must be %d-%d",
ErrLoudnessSettingOutOfRange, minBackfillConcurrency, maxBackfillConcurrency)
}
if in.LeveledCacheMB < minLeveledCacheMB || in.LeveledCacheMB > maxLeveledCacheMB {
return fmt.Errorf("%w: leveled_cache_mb must be %d-%d",
ErrLoudnessSettingOutOfRange, minLeveledCacheMB, maxLeveledCacheMB)
}
return nil
}
func loudnessSettingsFromRow(row dbq.LoudnessSetting) LoudnessSettings {
return LoudnessSettings{
Enabled: row.Enabled,
BackfillConcurrency: row.BackfillConcurrency,
LeveledCacheMB: row.LeveledCacheMb,
UpdatedAt: row.UpdatedAt.Time,
}
}