feat: leveled FLAC stream for Sonos/UPnP speakers (M464 #5001)
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
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
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>
This commit is contained in:
@@ -0,0 +1,339 @@
|
||||
package library
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"math"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sync/singleflight"
|
||||
)
|
||||
|
||||
// Leveled streams (M464 #5001).
|
||||
//
|
||||
// Sonos and other UPnP renderers fetch a track's URL themselves, so the phone
|
||||
// that sent them there cannot change what they play. For them the server
|
||||
// renders the track with its gain already applied: a FLAC, written to a cache
|
||||
// directory and served as a plain file, so Content-Length, Range and seeking
|
||||
// behave as for the original. Tags are stripped; the renderer is told the
|
||||
// metadata in the DIDL-Lite it is handed, and an embedded ReplayGain tag would
|
||||
// invite it to adjust a second time.
|
||||
|
||||
// The same limits as the web and Android players (web/src/lib/player/gain.ts,
|
||||
// android .../player/gain/GainMath.kt): headroom mode stops a boost 1 dB under
|
||||
// the true peak, and no boost exceeds 12 dB.
|
||||
const (
|
||||
leveledPeakCeilingDBTP = -1.0
|
||||
leveledMaxBoostDB = 12.0
|
||||
// The cut is bounded too: a gain is a request parameter, and a value no
|
||||
// measurement could produce is refused rather than rendered.
|
||||
leveledMaxCutDB = -60.0
|
||||
)
|
||||
|
||||
// LeveledGainDB is the gain, in dB, a track gets under prefs: 0 when leveling
|
||||
// is off or the track has not been measured. asAlbum says whether the track is
|
||||
// being played as part of its album in order, which only the client that
|
||||
// holds the queue can know.
|
||||
func LeveledGainDB(prefs NormalizationPrefs, g ReplayGain, asAlbum bool) float64 {
|
||||
if prefs.Mode == "off" {
|
||||
return 0
|
||||
}
|
||||
wantAlbum := prefs.Mode == "album" || (prefs.Mode == "auto" && asAlbum)
|
||||
// Album gain falls back to track gain while the album is still being
|
||||
// measured; track gain never falls back to album gain.
|
||||
gain, peak := g.TrackGain, g.TrackPeak
|
||||
if wantAlbum && g.AlbumGain != nil {
|
||||
gain, peak = g.AlbumGain, g.AlbumPeak
|
||||
}
|
||||
if gain == nil {
|
||||
return 0
|
||||
}
|
||||
db := float64(*gain) + float64(prefs.TargetLUFS) - ReplayGainReferenceLUFS
|
||||
if prefs.Boost == "headroom" && peak != nil && *peak > 0 {
|
||||
db = math.Min(db, leveledPeakCeilingDBTP-20*math.Log10(float64(*peak)))
|
||||
}
|
||||
return math.Min(db, leveledMaxBoostDB)
|
||||
}
|
||||
|
||||
// LeveledGain is a render request: a gain in hundredths of a dB, and whether
|
||||
// a limiter holds the peaks of a boost. Hundredths, so it travels in a URL and
|
||||
// a signature exactly.
|
||||
type LeveledGain struct {
|
||||
CentiDB int
|
||||
Limiter bool
|
||||
}
|
||||
|
||||
// NewLeveledGain rounds db to a render request. The limiter is only ever
|
||||
// engaged for a boost: a cut cannot raise a peak.
|
||||
func NewLeveledGain(db float64, boost string) LeveledGain {
|
||||
c := int(math.Round(db * 100))
|
||||
return LeveledGain{CentiDB: c, Limiter: boost == "limiter" && c > 0}
|
||||
}
|
||||
|
||||
// Unity reports whether the request would reproduce the original.
|
||||
func (g LeveledGain) Unity() bool { return g.CentiDB == 0 && !g.Limiter }
|
||||
|
||||
// Valid reports whether the gain is one a measurement could have produced.
|
||||
func (g LeveledGain) Valid() bool {
|
||||
return g.CentiDB >= int(leveledMaxCutDB*100) && g.CentiDB <= int(leveledMaxBoostDB*100)
|
||||
}
|
||||
|
||||
// leveledFilter is the ffmpeg audio filter for g. The limiter's ceiling is
|
||||
// -1 dBFS (0.891 linear); level=disabled stops alimiter from raising the
|
||||
// output back to full scale afterwards, which would undo the leveling.
|
||||
func leveledFilter(g LeveledGain) string {
|
||||
f := "volume=" + strconv.FormatFloat(float64(g.CentiDB)/100, 'f', 2, 64) + "dB"
|
||||
if g.Limiter {
|
||||
f += ",alimiter=limit=0.891:level=disabled"
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// leveledSource is what the renderer needs to know about the original file.
|
||||
type leveledSource struct {
|
||||
SampleRate int
|
||||
// Bits is the source's sample depth; 0 for a lossy source, which has none.
|
||||
Bits int
|
||||
}
|
||||
|
||||
// leveledRenderArgs is the ffmpeg command line rendering src to dst. Output is
|
||||
// FLAC at the source's depth (24-bit for a hi-res source, 16 otherwise) and at
|
||||
// most 48 kHz: renderers that take FLAC take those, and few take more.
|
||||
func leveledRenderArgs(src, dst string, g LeveledGain, s leveledSource) []string {
|
||||
args := []string{
|
||||
"-hide_banner", "-nostdin", "-nostats", "-loglevel", "error",
|
||||
"-i", src,
|
||||
"-map", "0:a:0",
|
||||
"-map_metadata", "-1",
|
||||
"-af", leveledFilter(g),
|
||||
"-c:a", "flac",
|
||||
}
|
||||
if s.Bits > 16 {
|
||||
args = append(args, "-sample_fmt", "s32", "-bits_per_raw_sample", "24")
|
||||
} else {
|
||||
args = append(args, "-sample_fmt", "s16")
|
||||
}
|
||||
if s.SampleRate > 48000 {
|
||||
args = append(args, "-ar", "48000")
|
||||
}
|
||||
return append(args, "-f", "flac", "-y", dst)
|
||||
}
|
||||
|
||||
// leveledCacheKey names the rendered file. It changes with the source file's
|
||||
// size and modification time, so a replaced file is never served from an old
|
||||
// render, and with leveledRenderVersion, so a change to how renders are made
|
||||
// retires the old ones.
|
||||
func leveledCacheKey(trackID string, info fs.FileInfo, g LeveledGain) string {
|
||||
h := sha256.New()
|
||||
_, _ = fmt.Fprintf(h, "%d|%s|%d|%d|%d|%t",
|
||||
leveledRenderVersion, trackID, info.Size(), info.ModTime().UnixNano(), g.CentiDB, g.Limiter)
|
||||
return hex.EncodeToString(h.Sum(nil))[:32] + ".flac"
|
||||
}
|
||||
|
||||
const leveledRenderVersion = 1
|
||||
|
||||
// LeveledSource identifies the original a render is made from.
|
||||
type LeveledSource struct {
|
||||
TrackID string
|
||||
Path string
|
||||
DurationMs int32
|
||||
}
|
||||
|
||||
// LeveledRenderer renders leveled FLACs into a cache directory and keeps that
|
||||
// directory under the size the loudness settings allow. Safe for concurrent
|
||||
// use: renders of the same file and gain are coalesced into one.
|
||||
type LeveledRenderer struct {
|
||||
dir string
|
||||
settings *LoudnessSettingsService
|
||||
logger *slog.Logger
|
||||
group singleflight.Group
|
||||
evictMu sync.Mutex
|
||||
|
||||
// render and probe are ffmpeg and ffprobe; tests replace them.
|
||||
render func(ctx context.Context, src, dst string, g LeveledGain, s leveledSource) error
|
||||
probe func(ctx context.Context, src string) (leveledSource, error)
|
||||
}
|
||||
|
||||
// NewLeveledRenderer renders into dir, creating it if needed.
|
||||
func NewLeveledRenderer(dir string, settings *LoudnessSettingsService, logger *slog.Logger) (*LeveledRenderer, error) {
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
return nil, fmt.Errorf("leveled cache: %w", err)
|
||||
}
|
||||
return &LeveledRenderer{
|
||||
dir: dir,
|
||||
settings: settings,
|
||||
logger: logger,
|
||||
render: ffmpegRenderLeveled,
|
||||
probe: ffprobeLeveledSource,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ErrLeveledSourceMissing is returned when the original file cannot be read.
|
||||
var ErrLeveledSourceMissing = errors.New("leveled: source file missing")
|
||||
|
||||
// Path returns the rendered file for src at gain g, rendering it first if it
|
||||
// is not cached. A caller arriving while the same render runs waits for it
|
||||
// rather than starting another.
|
||||
func (r *LeveledRenderer) Path(ctx context.Context, src LeveledSource, g LeveledGain) (string, error) {
|
||||
info, err := os.Stat(src.Path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: %w", ErrLeveledSourceMissing, err)
|
||||
}
|
||||
dst := filepath.Join(r.dir, leveledCacheKey(src.TrackID, info, g))
|
||||
if _, err := os.Stat(dst); err == nil {
|
||||
// A hit counts as a use, for eviction.
|
||||
now := time.Now()
|
||||
_ = os.Chtimes(dst, now, now)
|
||||
return dst, nil
|
||||
}
|
||||
// The render runs detached from the caller: a renderer that gives up on
|
||||
// one request (Sonos retries quickly) must not cancel the render a second
|
||||
// request is about to wait on.
|
||||
ch := r.group.DoChan(dst, func() (any, error) {
|
||||
rctx, cancel := context.WithTimeout(context.Background(), loudnessTimeout(src.DurationMs))
|
||||
defer cancel()
|
||||
return dst, r.renderTo(rctx, src.Path, dst, g)
|
||||
})
|
||||
select {
|
||||
case res := <-ch:
|
||||
if res.Err != nil {
|
||||
return "", res.Err
|
||||
}
|
||||
return dst, nil
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// Prerender starts rendering src at g in the background, so the renderer's
|
||||
// fetch finds it ready. Used when a leveled URL is handed out: the speaker
|
||||
// asks for the next track shortly before it plays.
|
||||
func (r *LeveledRenderer) Prerender(src LeveledSource, g LeveledGain) {
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), loudnessTimeout(src.DurationMs))
|
||||
defer cancel()
|
||||
if _, err := r.Path(ctx, src, g); err != nil {
|
||||
r.logger.Warn("leveled: prerender failed", "track", src.TrackID, "err", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (r *LeveledRenderer) renderTo(ctx context.Context, src, dst string, g LeveledGain) error {
|
||||
s, err := r.probe(ctx, src)
|
||||
if err != nil {
|
||||
return fmt.Errorf("leveled: probe: %w", err)
|
||||
}
|
||||
// Rendered beside the destination and renamed into place, so a reader
|
||||
// never sees a half-written file and a failed render leaves nothing.
|
||||
tmp := dst + ".part"
|
||||
if err := r.render(ctx, src, tmp, g, s); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return fmt.Errorf("leveled: render: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmp, dst); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return fmt.Errorf("leveled: %w", err)
|
||||
}
|
||||
r.evict(dst)
|
||||
return nil
|
||||
}
|
||||
|
||||
// evict removes the least recently used renders until the cache fits the
|
||||
// configured size. keep is never removed: it is the render about to be served.
|
||||
func (r *LeveledRenderer) evict(keep string) {
|
||||
r.evictMu.Lock()
|
||||
defer r.evictMu.Unlock()
|
||||
limit := int64(r.settings.Get().LeveledCacheMB) << 20
|
||||
entries, err := os.ReadDir(r.dir)
|
||||
if err != nil {
|
||||
r.logger.Warn("leveled: read cache", "err", err)
|
||||
return
|
||||
}
|
||||
type cached struct {
|
||||
path string
|
||||
size int64
|
||||
used time.Time
|
||||
}
|
||||
var files []cached
|
||||
var total int64
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".flac") {
|
||||
continue
|
||||
}
|
||||
info, err := e.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
files = append(files, cached{filepath.Join(r.dir, e.Name()), info.Size(), info.ModTime()})
|
||||
total += info.Size()
|
||||
}
|
||||
slices.SortFunc(files, func(a, b cached) int { return a.used.Compare(b.used) })
|
||||
for _, f := range files {
|
||||
if total <= limit {
|
||||
return
|
||||
}
|
||||
if f.path == keep {
|
||||
continue
|
||||
}
|
||||
// A file still being sent stays readable: the open descriptor holds it.
|
||||
if err := os.Remove(f.path); err == nil {
|
||||
total -= f.size
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func ffmpegRenderLeveled(ctx context.Context, src, dst string, g LeveledGain, s leveledSource) error {
|
||||
out, err := exec.CommandContext(ctx, "ffmpeg", leveledRenderArgs(src, dst, g, s)...).CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("ffmpeg: %w: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ffprobeLeveledSource reads the first audio stream's rate and depth.
|
||||
// bits_per_raw_sample is set for lossless codecs and 0 or N/A for lossy ones.
|
||||
func ffprobeLeveledSource(ctx context.Context, src string) (leveledSource, error) {
|
||||
out, err := exec.CommandContext(ctx, "ffprobe",
|
||||
"-v", "error", "-select_streams", "a:0",
|
||||
"-show_entries", "stream=sample_rate,bits_per_raw_sample,bits_per_sample",
|
||||
"-of", "default=noprint_wrappers=1", src).Output()
|
||||
if err != nil {
|
||||
return leveledSource{}, fmt.Errorf("ffprobe: %w", err)
|
||||
}
|
||||
return parseLeveledProbe(string(out)), nil
|
||||
}
|
||||
|
||||
// parseLeveledProbe reads ffprobe's key=value lines. Unknown or N/A values
|
||||
// read as 0: a 16-bit, at-most-48 kHz render, which suits every source.
|
||||
func parseLeveledProbe(out string) leveledSource {
|
||||
var s leveledSource
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
k, v, ok := strings.Cut(strings.TrimSpace(line), "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
switch k {
|
||||
case "sample_rate":
|
||||
s.SampleRate = n
|
||||
case "bits_per_raw_sample", "bits_per_sample":
|
||||
s.Bits = max(s.Bits, n)
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
package library
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func f32(v float32) *float32 { return &v }
|
||||
|
||||
// The same cases as web/src/lib/player/gain.test.ts and Android's
|
||||
// GainMathTest: a speaker must level a track exactly as the phone would.
|
||||
func TestLeveledGainDB(t *testing.T) {
|
||||
g := ReplayGain{TrackGain: f32(-6), TrackPeak: f32(1), AlbumGain: f32(-4), AlbumPeak: f32(1)}
|
||||
auto := DefaultNormalizationPrefs
|
||||
with := func(mode string, target int16, boost string) NormalizationPrefs {
|
||||
return NormalizationPrefs{Mode: mode, TargetLUFS: target, Boost: boost}
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
prefs NormalizationPrefs
|
||||
g ReplayGain
|
||||
asAlbum bool
|
||||
want float64
|
||||
}{
|
||||
{"off", with("off", -18, "headroom"), g, false, 0},
|
||||
{"unmeasured", auto, ReplayGain{}, false, 0},
|
||||
{"track mode ignores album play", with("track", -18, "headroom"), g, true, -6},
|
||||
{"album mode", with("album", -18, "headroom"), g, false, -4},
|
||||
{"auto in album order", auto, g, true, -4},
|
||||
{"auto in a mix", auto, g, false, -6},
|
||||
{"album falls back to track", with("album", -18, "headroom"), ReplayGain{TrackGain: f32(-6), TrackPeak: f32(1)}, true, -6},
|
||||
{"louder target", with("track", -14, "headroom"), g, false, -2},
|
||||
{"headroom stops under the peak", with("track", -18, "headroom"), ReplayGain{TrackGain: f32(8), TrackPeak: f32(0.5)}, false, -1 - 20*math.Log10(0.5)},
|
||||
{"limiter lets the boost through", with("track", -18, "limiter"), ReplayGain{TrackGain: f32(8), TrackPeak: f32(0.5)}, false, 8},
|
||||
{"boost cap", with("track", -18, "limiter"), ReplayGain{TrackGain: f32(30), TrackPeak: f32(0.001)}, false, 12},
|
||||
{"cuts ignore the peak", with("track", -18, "headroom"), ReplayGain{TrackGain: f32(-9), TrackPeak: f32(1.4)}, false, -9},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := LeveledGainDB(c.prefs, c.g, c.asAlbum); math.Abs(got-c.want) > 1e-4 {
|
||||
t.Errorf("%s: gain = %.4f, want %.4f", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewLeveledGain(t *testing.T) {
|
||||
if g := NewLeveledGain(-6.126, "limiter"); g.CentiDB != -613 || g.Limiter {
|
||||
t.Errorf("cut = %+v, want -613 without the limiter: a cut cannot raise a peak", g)
|
||||
}
|
||||
if g := NewLeveledGain(3.2, "limiter"); g.CentiDB != 320 || !g.Limiter {
|
||||
t.Errorf("boost = %+v, want 320 with the limiter", g)
|
||||
}
|
||||
if g := NewLeveledGain(3.2, "headroom"); g.Limiter {
|
||||
t.Errorf("headroom boost engaged the limiter: %+v", g)
|
||||
}
|
||||
if !NewLeveledGain(0.001, "limiter").Unity() {
|
||||
t.Error("a gain that rounds to 0 should be unity")
|
||||
}
|
||||
for _, c := range []struct {
|
||||
g LeveledGain
|
||||
want bool
|
||||
}{{LeveledGain{CentiDB: 1200}, true}, {LeveledGain{CentiDB: 1201}, false}, {LeveledGain{CentiDB: -6000}, true}, {LeveledGain{CentiDB: -6001}, false}} {
|
||||
if c.g.Valid() != c.want {
|
||||
t.Errorf("Valid(%d) = %v, want %v", c.g.CentiDB, !c.want, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeveledRenderArgs(t *testing.T) {
|
||||
join := func(a []string) string { return strings.Join(a, " ") }
|
||||
|
||||
hires := join(leveledRenderArgs("in.flac", "out.flac", LeveledGain{CentiDB: 350, Limiter: true}, leveledSource{SampleRate: 96000, Bits: 24}))
|
||||
for _, want := range []string{
|
||||
"-map_metadata -1",
|
||||
"-af volume=3.50dB,alimiter=limit=0.891:level=disabled",
|
||||
"-c:a flac",
|
||||
"-sample_fmt s32 -bits_per_raw_sample 24",
|
||||
"-ar 48000",
|
||||
"-f flac -y out.flac",
|
||||
} {
|
||||
if !strings.Contains(hires, want) {
|
||||
t.Errorf("hi-res args %q lack %q", hires, want)
|
||||
}
|
||||
}
|
||||
|
||||
lossy := join(leveledRenderArgs("in.mp3", "out.flac", LeveledGain{CentiDB: -612}, leveledSource{SampleRate: 44100}))
|
||||
if !strings.Contains(lossy, "-af volume=-6.12dB -c:a") || strings.Contains(lossy, "alimiter") {
|
||||
t.Errorf("cut args %q: want a plain volume filter", lossy)
|
||||
}
|
||||
if !strings.Contains(lossy, "-sample_fmt s16") || strings.Contains(lossy, "-ar ") {
|
||||
t.Errorf("lossy 44.1 kHz args %q: want 16-bit at the source rate", lossy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLeveledProbe(t *testing.T) {
|
||||
got := parseLeveledProbe("sample_rate=96000\nbits_per_sample=0\nbits_per_raw_sample=24\n")
|
||||
if got != (leveledSource{SampleRate: 96000, Bits: 24}) {
|
||||
t.Errorf("flac probe = %+v", got)
|
||||
}
|
||||
got = parseLeveledProbe("sample_rate=44100\nbits_per_sample=0\nbits_per_raw_sample=N/A\n")
|
||||
if got != (leveledSource{SampleRate: 44100}) {
|
||||
t.Errorf("mp3 probe = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// testRenderer renders by writing size bytes, counting the renders it runs.
|
||||
func testRenderer(t *testing.T, cacheMB int32, size int) (*LeveledRenderer, *atomic.Int32, chan struct{}) {
|
||||
t.Helper()
|
||||
settings := &LoudnessSettingsService{cur: DefaultLoudnessSettings}
|
||||
settings.cur.LeveledCacheMB = cacheMB
|
||||
r, err := NewLeveledRenderer(t.TempDir(), settings, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var renders atomic.Int32
|
||||
gate := make(chan struct{})
|
||||
close(gate) // open unless a test replaces it
|
||||
r.probe = func(context.Context, string) (leveledSource, error) { return leveledSource{}, nil }
|
||||
r.render = func(_ context.Context, _, dst string, _ LeveledGain, _ leveledSource) error {
|
||||
<-gate
|
||||
renders.Add(1)
|
||||
return os.WriteFile(dst, make([]byte, size), 0o600)
|
||||
}
|
||||
return r, &renders, gate
|
||||
}
|
||||
|
||||
func writeSource(t *testing.T, name string) LeveledSource {
|
||||
t.Helper()
|
||||
p := filepath.Join(t.TempDir(), name)
|
||||
if err := os.WriteFile(p, []byte("audio"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return LeveledSource{TrackID: name, Path: p, DurationMs: 1000}
|
||||
}
|
||||
|
||||
func TestLeveledRenderer_CoalescesAndCaches(t *testing.T) {
|
||||
r, renders, _ := testRenderer(t, 2048, 10)
|
||||
gate := make(chan struct{})
|
||||
r.render = func(_ context.Context, _, dst string, _ LeveledGain, _ leveledSource) error {
|
||||
<-gate
|
||||
renders.Add(1)
|
||||
return os.WriteFile(dst, []byte("flac"), 0o600)
|
||||
}
|
||||
src := writeSource(t, "a")
|
||||
g := LeveledGain{CentiDB: -300}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
paths := make([]string, 5)
|
||||
for i := range paths {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
p, err := r.Path(context.Background(), src, g)
|
||||
if err != nil {
|
||||
t.Errorf("Path: %v", err)
|
||||
}
|
||||
paths[i] = p
|
||||
}()
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond) // let every caller reach the render
|
||||
close(gate)
|
||||
wg.Wait()
|
||||
if n := renders.Load(); n != 1 {
|
||||
t.Fatalf("5 concurrent requests ran %d renders, want 1", n)
|
||||
}
|
||||
for _, p := range paths {
|
||||
if p != paths[0] {
|
||||
t.Fatalf("callers got different files: %v", paths)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := r.Path(context.Background(), src, g); err != nil || renders.Load() != 1 {
|
||||
t.Fatalf("a cached render was rendered again (renders %d, err %v)", renders.Load(), err)
|
||||
}
|
||||
if _, err := r.Path(context.Background(), src, LeveledGain{CentiDB: -200}); err != nil || renders.Load() != 2 {
|
||||
t.Fatalf("a different gain did not render anew (renders %d, err %v)", renders.Load(), err)
|
||||
}
|
||||
// A replaced file is a different source: the old render must not serve it.
|
||||
later := time.Now().Add(time.Hour)
|
||||
if err := os.Chtimes(src.Path, later, later); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := r.Path(context.Background(), src, g); err != nil || renders.Load() != 3 {
|
||||
t.Fatalf("a changed source was served from the old render (renders %d, err %v)", renders.Load(), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeveledRenderer_MissingSource(t *testing.T) {
|
||||
r, _, _ := testRenderer(t, 2048, 10)
|
||||
_, err := r.Path(context.Background(), LeveledSource{TrackID: "x", Path: "/nonexistent/x.flac"}, LeveledGain{CentiDB: 100})
|
||||
if !errors.Is(err, ErrLeveledSourceMissing) {
|
||||
t.Fatalf("err = %v, want ErrLeveledSourceMissing", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeveledRenderer_FailedRenderLeavesNothing(t *testing.T) {
|
||||
r, _, _ := testRenderer(t, 2048, 10)
|
||||
r.render = func(_ context.Context, _, dst string, _ LeveledGain, _ leveledSource) error {
|
||||
_ = os.WriteFile(dst, []byte("half"), 0o600)
|
||||
return errors.New("ffmpeg exited 1")
|
||||
}
|
||||
if _, err := r.Path(context.Background(), writeSource(t, "a"), LeveledGain{CentiDB: 100}); err == nil {
|
||||
t.Fatal("a failed render reported success")
|
||||
}
|
||||
if entries, _ := os.ReadDir(r.dir); len(entries) != 0 {
|
||||
t.Fatalf("a failed render left %d files behind", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeveledRenderer_EvictsLeastRecentlyUsed(t *testing.T) {
|
||||
// 1 MB cap, 400 KB renders: the third render pushes out the oldest.
|
||||
r, _, _ := testRenderer(t, 1, 400<<10)
|
||||
a, b, c := writeSource(t, "a"), writeSource(t, "b"), writeSource(t, "c")
|
||||
g := LeveledGain{CentiDB: 100}
|
||||
pa, _ := r.Path(context.Background(), a, g)
|
||||
past := time.Now().Add(-time.Hour)
|
||||
_ = os.Chtimes(pa, past, past)
|
||||
pb, _ := r.Path(context.Background(), b, g)
|
||||
_ = os.Chtimes(pb, past.Add(time.Minute), past.Add(time.Minute))
|
||||
// Using a again makes b the least recently used.
|
||||
if _, err := r.Path(context.Background(), a, g); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pc, _ := r.Path(context.Background(), c, g)
|
||||
|
||||
var left []string
|
||||
entries, _ := os.ReadDir(r.dir)
|
||||
for _, e := range entries {
|
||||
left = append(left, filepath.Join(r.dir, e.Name()))
|
||||
}
|
||||
if slices.Contains(left, pb) || !slices.Contains(left, pa) || !slices.Contains(left, pc) {
|
||||
t.Fatalf("cache after eviction = %v; want a and c kept, b (least recently used) gone", left)
|
||||
}
|
||||
}
|
||||
@@ -22,17 +22,27 @@ type LoudnessSettings struct {
|
||||
// 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 migration 0065's column defaults, so a
|
||||
// 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")
|
||||
@@ -78,6 +88,7 @@ func (s *LoudnessSettingsService) Set(ctx context.Context, in LoudnessSettings)
|
||||
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)
|
||||
@@ -94,6 +105,10 @@ func validateLoudnessSettings(in LoudnessSettings) error {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -101,6 +116,7 @@ func loudnessSettingsFromRow(row dbq.LoudnessSetting) LoudnessSettings {
|
||||
return LoudnessSettings{
|
||||
Enabled: row.Enabled,
|
||||
BackfillConcurrency: row.BackfillConcurrency,
|
||||
LeveledCacheMB: row.LeveledCacheMb,
|
||||
UpdatedAt: row.UpdatedAt.Time,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,14 +233,27 @@ func TestBackfillLoudnessResult_Add(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestValidateLoudnessSettings(t *testing.T) {
|
||||
for _, n := range []int32{minBackfillConcurrency, maxBackfillConcurrency} {
|
||||
if err := validateLoudnessSettings(LoudnessSettings{BackfillConcurrency: n}); err != nil {
|
||||
t.Errorf("concurrency %d rejected: %v", n, err)
|
||||
with := func(concurrency, cacheMB int32) LoudnessSettings {
|
||||
s := DefaultLoudnessSettings
|
||||
s.BackfillConcurrency, s.LeveledCacheMB = concurrency, cacheMB
|
||||
return s
|
||||
}
|
||||
for _, s := range []LoudnessSettings{
|
||||
with(minBackfillConcurrency, minLeveledCacheMB),
|
||||
with(maxBackfillConcurrency, maxLeveledCacheMB),
|
||||
} {
|
||||
if err := validateLoudnessSettings(s); err != nil {
|
||||
t.Errorf("%+v rejected: %v", s, err)
|
||||
}
|
||||
}
|
||||
for _, n := range []int32{0, maxBackfillConcurrency + 1} {
|
||||
if err := validateLoudnessSettings(LoudnessSettings{BackfillConcurrency: n}); !errors.Is(err, ErrLoudnessSettingOutOfRange) {
|
||||
t.Errorf("concurrency %d: err = %v, want ErrLoudnessSettingOutOfRange", n, err)
|
||||
for _, s := range []LoudnessSettings{
|
||||
with(0, minLeveledCacheMB),
|
||||
with(maxBackfillConcurrency+1, minLeveledCacheMB),
|
||||
with(minBackfillConcurrency, minLeveledCacheMB-1),
|
||||
with(minBackfillConcurrency, maxLeveledCacheMB+1),
|
||||
} {
|
||||
if err := validateLoudnessSettings(s); !errors.Is(err, ErrLoudnessSettingOutOfRange) {
|
||||
t.Errorf("%+v: err = %v, want ErrLoudnessSettingOutOfRange", s, err)
|
||||
}
|
||||
}
|
||||
var nilSvc *LoudnessSettingsService
|
||||
|
||||
Reference in New Issue
Block a user