feat(library): measure every track's loudness in the background (M464 #4995)
release / go (push) Failing after 1m18s
release / web (push) Successful in 1m27s
release / govulncheck (push) Successful in 35s
release / integration (push) Failing after 4m13s
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 / android (push) Canceled after 5m47s
release / Build signed APK (releases and dev) (push) Canceled after 4m21s

The first step of loudness normalization: the server measures each track
with ffmpeg's EBU R128 filter (true peak, mono as dual mono) and stores the
integrated loudness, true peak and loudness range in track_loudness
(migration 0065).

It also keeps a histogram of the 400 ms gating blocks at 0.1 LU, so album
loudness can be computed exactly later with no second decode (#4996). The
histogram reproduces ffmpeg's own figure (-10.68 against -10.7 on the
captured fixture), and the analyzer logs a warning if the two ever drift.

- A background worker, cloned from the fingerprint backfill, measures every
  track, new ones included. Measuring inline in the scan was dropped: the
  analysis decodes the whole file, and a large import could pass the scan's
  one-hour stuck threshold. The scan only deletes a changed file's
  measurement; the worker ticks every 10 minutes.
- Timeouts, the cancel/missing-binary split and settled verdicts follow the
  fingerprint runner. Silence and undecodable files are stored as verdicts;
  stalls are retried. The deadline scales with track length.
- loudness_settings (enabled, files at once) and an admin card with the
  coverage gauge, under GET/PUT /api/admin/library/loudness-settings and
  GET /api/admin/library/loudness.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 13:01:01 -04:00
co-authored by Claude Opus 5.5
parent edd9a3a6db
commit f3196b3443
22 changed files with 2139 additions and 3 deletions
+82
View File
@@ -0,0 +1,82 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
)
// loudnessCoverageResp is the wire shape for GET /api/admin/library/loudness
// (M464 #4995). measured + silent + unreadable + pending = total; missing
// tracks are not counted. Enabled travels with the counts because with
// analysis off, pending never shrinks, and a gauge implying progress would be
// promising work nothing is doing.
type loudnessCoverageResp struct {
Total int64 `json:"total"`
Measured int64 `json:"measured"`
Silent int64 `json:"silent"`
Unreadable int64 `json:"unreadable"`
Pending int64 `json:"pending"`
Enabled bool `json:"enabled"`
}
// handleGetLoudnessCoverage implements GET /api/admin/library/loudness: how
// far the loudness backfill has got. Always 200; zeros on an empty library.
func (h *handlers) handleGetLoudnessCoverage(w http.ResponseWriter, r *http.Request) {
row, err := library.LoudnessCoverage(r.Context(), h.pool)
if err != nil {
writeErrWithLog(w, h.logger, "admin: get loudness coverage", apierror.InternalMsg("lookup failed", err))
return
}
writeJSON(w, http.StatusOK, loudnessCoverageResp{
Total: row.Total,
Measured: row.Measured,
Silent: row.Silent,
Unreadable: row.Unreadable,
Pending: row.Pending,
Enabled: h.loudnessSettings.Get().Enabled,
})
}
// loudnessSettingsBody is the wire shape for GET and PUT
// /api/admin/library/loudness-settings.
type loudnessSettingsBody struct {
Enabled bool `json:"enabled"`
BackfillConcurrency int32 `json:"backfill_concurrency"`
}
func loudnessSettingsBodyOf(s library.LoudnessSettings) loudnessSettingsBody {
return loudnessSettingsBody{Enabled: s.Enabled, BackfillConcurrency: s.BackfillConcurrency}
}
// handleGetLoudnessSettings implements GET /api/admin/library/loudness-settings.
func (h *handlers) handleGetLoudnessSettings(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, loudnessSettingsBodyOf(h.loudnessSettings.Get()))
}
// handleUpdateLoudnessSettings implements PUT /api/admin/library/loudness-settings.
// A whole-row write: a body that leaves out the concurrency decodes it as zero,
// which is refused rather than saved.
func (h *handlers) handleUpdateLoudnessSettings(w http.ResponseWriter, r *http.Request) {
var req loudnessSettingsBody
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, apierror.BadRequest("invalid_body", "malformed JSON"))
return
}
saved, err := h.loudnessSettings.Set(r.Context(), library.LoudnessSettings{
Enabled: req.Enabled,
BackfillConcurrency: req.BackfillConcurrency,
})
if err != nil {
if errors.Is(err, library.ErrLoudnessSettingOutOfRange) {
writeErr(w, apierror.BadRequest("invalid_setting", err.Error()))
return
}
writeErrWithLog(w, h.logger, "admin loudness settings: update failed", apierror.Internal(err))
return
}
writeJSON(w, http.StatusOK, loudnessSettingsBodyOf(saved))
}
+8 -1
View File
@@ -34,7 +34,7 @@ import (
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
// RequireUser; everything else is gated by the middleware. The events writer
// is shared with the Subsonic mount so /rest/scrobble feeds the same store.
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, recSettings *recsettings.Service, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, tagSettings *tags.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte, netSettings *netsettings.Service, reacqSettings *reacquisition.SettingsService, fpSettings *library.FingerprintSettingsService) {
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, recSettings *recsettings.Service, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, tagSettings *tags.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte, netSettings *netsettings.Service, reacqSettings *reacquisition.SettingsService, fpSettings *library.FingerprintSettingsService, loudSettings *library.LoudnessSettingsService) {
rng := rand.New(rand.NewSource(rand.Int63()))
setupToken, err := auth.NewSetupToken()
if err != nil {
@@ -65,6 +65,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
netSettings: netSettings,
reacqSettings: reacqSettings,
fingerprintSettings: fpSettings,
loudnessSettings: loudSettings,
librarySize: recommendation.NewLibrarySize(nil),
loginGuard: auth.NewLoginGuard(),
setupToken: setupToken,
@@ -237,6 +238,9 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
admin.Get("/library/fingerprints", h.handleGetFingerprintCoverage)
admin.Get("/library/fingerprint-settings", h.handleGetFingerprintSettings)
admin.Put("/library/fingerprint-settings", h.handleUpdateFingerprintSettings)
admin.Get("/library/loudness", h.handleGetLoudnessCoverage)
admin.Get("/library/loudness-settings", h.handleGetLoudnessSettings)
admin.Put("/library/loudness-settings", h.handleUpdateLoudnessSettings)
// Duplicates report (#3912): proposals from the duplicate sweep, a
// trigger to sweep now, dismissal, and the merge (#3911), which deletes
// the removed copies' files after moving their history onto the kept one.
@@ -331,6 +335,9 @@ type handlers struct {
// instance the scanner and the fingerprint workers read, so a save from the
// admin card reaches them without a restart. Nil serves the defaults.
fingerprintSettings *library.FingerprintSettingsService
// loudnessSettings is the loudness analysis policy (M464 #4995), the same
// instance the loudness backfill reads. Nil serves the defaults.
loudnessSettings *library.LoudnessSettingsService
// setupToken must accompany the first registration while no users exist
// (see auth.SetupToken). requireSetupToken is set by Mount, the only
// production constructor; tests that build handlers directly leave it