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
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:
@@ -132,6 +132,13 @@ func run() error {
|
|||||||
}
|
}
|
||||||
scanner := library.New(pool, logger, cfg.Library.ScanPaths, fpSettings)
|
scanner := library.New(pool, logger, cfg.Library.ScanPaths, fpSettings)
|
||||||
|
|
||||||
|
// Loudness analysis settings (M464 #4995): shared by the loudness backfill
|
||||||
|
// and the admin API, and falls back to the defaults like the above.
|
||||||
|
loudSettings, loudErr := library.NewLoudnessSettingsService(ctx, pool)
|
||||||
|
if loudErr != nil {
|
||||||
|
logger.Warn("loudness settings: using defaults", "err", loudErr)
|
||||||
|
}
|
||||||
|
|
||||||
contact := cfg.Library.ContactEmail
|
contact := cfg.Library.ContactEmail
|
||||||
if contact == "" {
|
if contact == "" {
|
||||||
contact = "https://git.fabledsword.com/bvandeusen/minstrel"
|
contact = "https://git.fabledsword.com/bvandeusen/minstrel"
|
||||||
@@ -228,6 +235,11 @@ func run() error {
|
|||||||
// internal/library/fingerprint_backfill.go for why.
|
// internal/library/fingerprint_backfill.go for why.
|
||||||
go library.NewFingerprintBackfillWorker(pool, logger.With("component", "fingerprint_backfill"), fpSettings).Run(ctx)
|
go library.NewFingerprintBackfillWorker(pool, logger.With("component", "fingerprint_backfill"), fpSettings).Run(ctx)
|
||||||
|
|
||||||
|
// Loudness backfill (M464 #4995): measures every track's loudness for
|
||||||
|
// normalization, new tracks included; the scan only drops a changed file's
|
||||||
|
// measurement. See internal/library/loudness_backfill.go.
|
||||||
|
go library.NewLoudnessBackfillWorker(pool, logger.With("component", "loudness_backfill"), loudSettings).Run(ctx)
|
||||||
|
|
||||||
// Duplicate sweep (M400 #3910): proposes groups of tracks holding one
|
// Duplicate sweep (M400 #3910): proposes groups of tracks holding one
|
||||||
// recording, from the fingerprints above. Sweeps only when fingerprints have
|
// recording, from the fingerprints above. Sweeps only when fingerprints have
|
||||||
// changed since the last sweep.
|
// changed since the last sweep.
|
||||||
@@ -377,6 +389,7 @@ func run() error {
|
|||||||
srv.RecSettings = recSettings
|
srv.RecSettings = recSettings
|
||||||
srv.TagSettings = tagSettings
|
srv.TagSettings = tagSettings
|
||||||
srv.FingerprintSettings = fpSettings
|
srv.FingerprintSettings = fpSettings
|
||||||
|
srv.LoudnessSettings = loudSettings
|
||||||
// The sweeper above holds this same instance, so a save from the admin
|
// The sweeper above holds this same instance, so a save from the admin
|
||||||
// card changes what it does on its next tick (#3936).
|
// card changes what it does on its next tick (#3936).
|
||||||
srv.ReacqSettings = reacqSettings
|
srv.ReacqSettings = reacqSettings
|
||||||
|
|||||||
@@ -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
@@ -34,7 +34,7 @@ import (
|
|||||||
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
|
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
|
||||||
// RequireUser; everything else is gated by the middleware. The events writer
|
// RequireUser; everything else is gated by the middleware. The events writer
|
||||||
// is shared with the Subsonic mount so /rest/scrobble feeds the same store.
|
// 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()))
|
rng := rand.New(rand.NewSource(rand.Int63()))
|
||||||
setupToken, err := auth.NewSetupToken()
|
setupToken, err := auth.NewSetupToken()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -65,6 +65,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
|||||||
netSettings: netSettings,
|
netSettings: netSettings,
|
||||||
reacqSettings: reacqSettings,
|
reacqSettings: reacqSettings,
|
||||||
fingerprintSettings: fpSettings,
|
fingerprintSettings: fpSettings,
|
||||||
|
loudnessSettings: loudSettings,
|
||||||
librarySize: recommendation.NewLibrarySize(nil),
|
librarySize: recommendation.NewLibrarySize(nil),
|
||||||
loginGuard: auth.NewLoginGuard(),
|
loginGuard: auth.NewLoginGuard(),
|
||||||
setupToken: setupToken,
|
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/fingerprints", h.handleGetFingerprintCoverage)
|
||||||
admin.Get("/library/fingerprint-settings", h.handleGetFingerprintSettings)
|
admin.Get("/library/fingerprint-settings", h.handleGetFingerprintSettings)
|
||||||
admin.Put("/library/fingerprint-settings", h.handleUpdateFingerprintSettings)
|
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
|
// Duplicates report (#3912): proposals from the duplicate sweep, a
|
||||||
// trigger to sweep now, dismissal, and the merge (#3911), which deletes
|
// trigger to sweep now, dismissal, and the merge (#3911), which deletes
|
||||||
// the removed copies' files after moving their history onto the kept one.
|
// 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
|
// instance the scanner and the fingerprint workers read, so a save from the
|
||||||
// admin card reaches them without a restart. Nil serves the defaults.
|
// admin card reaches them without a restart. Nil serves the defaults.
|
||||||
fingerprintSettings *library.FingerprintSettingsService
|
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
|
// setupToken must accompany the first registration while no users exist
|
||||||
// (see auth.SetupToken). requireSetupToken is set by Mount, the only
|
// (see auth.SetupToken). requireSetupToken is set by Mount, the only
|
||||||
// production constructor; tests that build handlers directly leave it
|
// production constructor; tests that build handlers directly leave it
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.31.1
|
||||||
|
// source: loudness.sql
|
||||||
|
|
||||||
|
package dbq
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
const deleteTrackLoudness = `-- name: DeleteTrackLoudness :exec
|
||||||
|
DELETE FROM track_loudness WHERE track_id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
// The scan saw new bytes at this path. The stored measurement describes the old
|
||||||
|
// ones, so it goes, and the backfill measures the file again.
|
||||||
|
func (q *Queries) DeleteTrackLoudness(ctx context.Context, trackID pgtype.UUID) error {
|
||||||
|
_, err := q.db.Exec(ctx, deleteTrackLoudness, trackID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getLoudnessCoverage = `-- name: GetLoudnessCoverage :one
|
||||||
|
SELECT count(*)::bigint AS total,
|
||||||
|
count(*) FILTER (
|
||||||
|
WHERE l.analysis_version >= $1
|
||||||
|
AND l.integrated_lufs IS NOT NULL
|
||||||
|
)::bigint AS measured,
|
||||||
|
count(*) FILTER (
|
||||||
|
WHERE l.analysis_version >= $1
|
||||||
|
AND l.integrated_lufs IS NULL AND NOT l.unreadable
|
||||||
|
)::bigint AS silent,
|
||||||
|
count(*) FILTER (
|
||||||
|
WHERE l.analysis_version >= $1
|
||||||
|
AND l.unreadable
|
||||||
|
)::bigint AS unreadable,
|
||||||
|
count(*) FILTER (
|
||||||
|
WHERE l.track_id IS NULL OR l.analysis_version < $1
|
||||||
|
)::bigint AS pending
|
||||||
|
FROM tracks t
|
||||||
|
LEFT JOIN track_loudness l ON l.track_id = t.id
|
||||||
|
WHERE t.missing_since IS NULL
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetLoudnessCoverageRow struct {
|
||||||
|
Total int64
|
||||||
|
Measured int64
|
||||||
|
Silent int64
|
||||||
|
Unreadable int64
|
||||||
|
Pending int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// The admin gauge. measured + silent + unreadable + pending = total. Missing
|
||||||
|
// tracks are excluded, or the gauge could never reach the end.
|
||||||
|
func (q *Queries) GetLoudnessCoverage(ctx context.Context, currentVersion int16) (GetLoudnessCoverageRow, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getLoudnessCoverage, currentVersion)
|
||||||
|
var i GetLoudnessCoverageRow
|
||||||
|
err := row.Scan(
|
||||||
|
&i.Total,
|
||||||
|
&i.Measured,
|
||||||
|
&i.Silent,
|
||||||
|
&i.Unreadable,
|
||||||
|
&i.Pending,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getLoudnessSettings = `-- name: GetLoudnessSettings :one
|
||||||
|
SELECT id, enabled, backfill_concurrency, updated_at FROM loudness_settings WHERE id = true
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetLoudnessSettings(ctx context.Context) (LoudnessSetting, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getLoudnessSettings)
|
||||||
|
var i LoudnessSetting
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Enabled,
|
||||||
|
&i.BackfillConcurrency,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const listTracksNeedingLoudness = `-- name: ListTracksNeedingLoudness :many
|
||||||
|
SELECT t.id, t.file_path, t.duration_ms
|
||||||
|
FROM tracks t
|
||||||
|
LEFT JOIN track_loudness l ON l.track_id = t.id
|
||||||
|
WHERE t.missing_since IS NULL
|
||||||
|
AND (l.track_id IS NULL OR l.analysis_version < $1)
|
||||||
|
AND t.id > $2
|
||||||
|
ORDER BY t.id
|
||||||
|
LIMIT $3
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListTracksNeedingLoudnessParams struct {
|
||||||
|
CurrentVersion int16
|
||||||
|
AfterID pgtype.UUID
|
||||||
|
BatchLimit int32
|
||||||
|
}
|
||||||
|
|
||||||
|
type ListTracksNeedingLoudnessRow struct {
|
||||||
|
ID pgtype.UUID
|
||||||
|
FilePath string
|
||||||
|
DurationMs int32
|
||||||
|
}
|
||||||
|
|
||||||
|
// The backfill's work queue: tracks with no measurement, or one taken by an
|
||||||
|
// older method. Keyset-paged on id so a pass visits each track at most once;
|
||||||
|
// an inconclusive attempt writes no row, and without the cursor a file that
|
||||||
|
// keeps timing out would be listed again straight away. Missing tracks are
|
||||||
|
// skipped: there is no file to read.
|
||||||
|
func (q *Queries) ListTracksNeedingLoudness(ctx context.Context, arg ListTracksNeedingLoudnessParams) ([]ListTracksNeedingLoudnessRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listTracksNeedingLoudness, arg.CurrentVersion, arg.AfterID, arg.BatchLimit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []ListTracksNeedingLoudnessRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i ListTracksNeedingLoudnessRow
|
||||||
|
if err := rows.Scan(&i.ID, &i.FilePath, &i.DurationMs); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateLoudnessSettings = `-- name: UpdateLoudnessSettings :one
|
||||||
|
UPDATE loudness_settings
|
||||||
|
SET enabled = $1,
|
||||||
|
backfill_concurrency = $2,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = true
|
||||||
|
RETURNING id, enabled, backfill_concurrency, updated_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpdateLoudnessSettingsParams struct {
|
||||||
|
Enabled bool
|
||||||
|
BackfillConcurrency int32
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whole-row write from the admin card; migration 0065's CHECK is the backstop
|
||||||
|
// behind the service's own validation.
|
||||||
|
func (q *Queries) UpdateLoudnessSettings(ctx context.Context, arg UpdateLoudnessSettingsParams) (LoudnessSetting, error) {
|
||||||
|
row := q.db.QueryRow(ctx, updateLoudnessSettings, arg.Enabled, arg.BackfillConcurrency)
|
||||||
|
var i LoudnessSetting
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Enabled,
|
||||||
|
&i.BackfillConcurrency,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const upsertTrackLoudness = `-- name: UpsertTrackLoudness :exec
|
||||||
|
INSERT INTO track_loudness (
|
||||||
|
track_id, integrated_lufs, true_peak_dbtp, loudness_range_lu,
|
||||||
|
block_hist_start, block_hist, unreadable, analysis_version
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, $3,
|
||||||
|
$4, $5, $6,
|
||||||
|
$7, $8
|
||||||
|
)
|
||||||
|
ON CONFLICT (track_id) DO UPDATE SET
|
||||||
|
integrated_lufs = EXCLUDED.integrated_lufs,
|
||||||
|
true_peak_dbtp = EXCLUDED.true_peak_dbtp,
|
||||||
|
loudness_range_lu = EXCLUDED.loudness_range_lu,
|
||||||
|
block_hist_start = EXCLUDED.block_hist_start,
|
||||||
|
block_hist = EXCLUDED.block_hist,
|
||||||
|
unreadable = EXCLUDED.unreadable,
|
||||||
|
analysis_version = EXCLUDED.analysis_version,
|
||||||
|
analyzed_at = now()
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpsertTrackLoudnessParams struct {
|
||||||
|
TrackID pgtype.UUID
|
||||||
|
IntegratedLufs *float32
|
||||||
|
TruePeakDbtp *float32
|
||||||
|
LoudnessRangeLu *float32
|
||||||
|
BlockHistStart *int16
|
||||||
|
BlockHist []int32
|
||||||
|
Unreadable bool
|
||||||
|
AnalysisVersion int16
|
||||||
|
}
|
||||||
|
|
||||||
|
// Written when the backfill measures a track (#4995). Replaces the row
|
||||||
|
// wholesale: a measurement of the old bytes has no standing once the file has
|
||||||
|
// changed.
|
||||||
|
func (q *Queries) UpsertTrackLoudness(ctx context.Context, arg UpsertTrackLoudnessParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, upsertTrackLoudness,
|
||||||
|
arg.TrackID,
|
||||||
|
arg.IntegratedLufs,
|
||||||
|
arg.TruePeakDbtp,
|
||||||
|
arg.LoudnessRangeLu,
|
||||||
|
arg.BlockHistStart,
|
||||||
|
arg.BlockHist,
|
||||||
|
arg.Unreadable,
|
||||||
|
arg.AnalysisVersion,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -417,6 +417,13 @@ type LidarrRequest struct {
|
|||||||
LidarrAddConfirmedAt pgtype.Timestamptz
|
LidarrAddConfirmedAt pgtype.Timestamptz
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type LoudnessSetting struct {
|
||||||
|
ID bool
|
||||||
|
Enabled bool
|
||||||
|
BackfillConcurrency int32
|
||||||
|
UpdatedAt pgtype.Timestamptz
|
||||||
|
}
|
||||||
|
|
||||||
type MissingReacquisition struct {
|
type MissingReacquisition struct {
|
||||||
AlbumID pgtype.UUID
|
AlbumID pgtype.UUID
|
||||||
Attempts int32
|
Attempts int32
|
||||||
@@ -713,6 +720,18 @@ type TrackFingerprint struct {
|
|||||||
ChromaprintLengthSec int32
|
ChromaprintLengthSec int32
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TrackLoudness struct {
|
||||||
|
TrackID pgtype.UUID
|
||||||
|
IntegratedLufs *float32
|
||||||
|
TruePeakDbtp *float32
|
||||||
|
LoudnessRangeLu *float32
|
||||||
|
BlockHistStart *int16
|
||||||
|
BlockHist []int32
|
||||||
|
Unreadable bool
|
||||||
|
AnalysisVersion int16
|
||||||
|
AnalyzedAt pgtype.Timestamptz
|
||||||
|
}
|
||||||
|
|
||||||
type TrackSimilarity struct {
|
type TrackSimilarity struct {
|
||||||
TrackAID pgtype.UUID
|
TrackAID pgtype.UUID
|
||||||
TrackBID pgtype.UUID
|
TrackBID pgtype.UUID
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
DROP TABLE IF EXISTS loudness_settings;
|
||||||
|
DROP TABLE IF EXISTS track_loudness;
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
-- 0065_track_loudness.up.sql — measured loudness per track, for loudness
|
||||||
|
-- normalization (Scribe milestone #464, #4995).
|
||||||
|
--
|
||||||
|
-- Measured with ffmpeg's EBU R128 filter rather than read from ReplayGain tags:
|
||||||
|
-- tags in the wild are written against four different reference levels, and
|
||||||
|
-- most files have none. internal/library/loudness.go says how it is measured.
|
||||||
|
--
|
||||||
|
-- A table of its own rather than columns on tracks, for the reason
|
||||||
|
-- track_fingerprints is (0058): tracks is read with SELECT * on the hot path,
|
||||||
|
-- and the block histogram is a few hundred integers only album loudness reads.
|
||||||
|
--
|
||||||
|
-- What a row means, which the backfill depends on:
|
||||||
|
-- no row never analyzed, or the file changed since
|
||||||
|
-- analysis_version < current measured by an older method; measure again
|
||||||
|
-- analysis_version = current settled until the file changes:
|
||||||
|
-- integrated_lufs NOT NULL measured
|
||||||
|
-- integrated_lufs NULL, unreadable false
|
||||||
|
-- read fine, but no 400 ms block was above
|
||||||
|
-- the -70 LUFS gate: silence, or too short
|
||||||
|
-- unreadable true ffmpeg could not decode the file
|
||||||
|
-- A failure that says nothing about the file (a timeout, a cancelled pass, a
|
||||||
|
-- missing ffmpeg) writes no row, so the backfill tries again.
|
||||||
|
CREATE TABLE track_loudness (
|
||||||
|
track_id uuid PRIMARY KEY REFERENCES tracks (id) ON DELETE CASCADE,
|
||||||
|
-- Gated integrated loudness (ITU-R BS.1770), mono measured as dual mono.
|
||||||
|
integrated_lufs real,
|
||||||
|
-- Highest inter-sample peak, from 4x oversampling, in dB relative to full
|
||||||
|
-- scale. NULL for digital silence, whose peak is -inf.
|
||||||
|
true_peak_dbtp real,
|
||||||
|
-- Loudness range (EBU Tech 3342): how much the loudness moves within the
|
||||||
|
-- track. Not used for gain; kept because it costs nothing here.
|
||||||
|
loudness_range_lu real,
|
||||||
|
-- How many 400 ms gating blocks fell in each 0.1 LU bin. Bin i holds blocks
|
||||||
|
-- measuring -70.0 + (block_hist_start + i) / 10 LUFS; the array is trimmed
|
||||||
|
-- to the first and last non-empty bins. Album loudness is the gated loudness
|
||||||
|
-- of every block on the album, so it is computed from these exactly, with no
|
||||||
|
-- second decode (#4996).
|
||||||
|
block_hist_start smallint,
|
||||||
|
block_hist integer[],
|
||||||
|
unreadable boolean NOT NULL DEFAULT false,
|
||||||
|
analysis_version smallint NOT NULL,
|
||||||
|
analyzed_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
|
||||||
|
CONSTRAINT track_loudness_hist_pair
|
||||||
|
CHECK ((block_hist IS NULL) = (block_hist_start IS NULL))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Loudness analysis's knobs, in admin Settings. Rule 25: an operator setting is
|
||||||
|
-- a database row, changed without a restart. Singleton in the style of
|
||||||
|
-- fingerprint_settings (0061).
|
||||||
|
CREATE TABLE loudness_settings (
|
||||||
|
id boolean PRIMARY KEY DEFAULT true,
|
||||||
|
-- Off stops the background analysis. Tracks already measured keep their
|
||||||
|
-- values, so normalization keeps working for them.
|
||||||
|
enabled boolean NOT NULL DEFAULT true,
|
||||||
|
-- Files analyzed at once. Each is a full decode, competing with playback
|
||||||
|
-- transcoding for CPU and with streaming for the mount.
|
||||||
|
backfill_concurrency integer NOT NULL DEFAULT 2,
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
|
||||||
|
CONSTRAINT loudness_settings_singleton CHECK (id = true),
|
||||||
|
CONSTRAINT loudness_settings_concurrency_range
|
||||||
|
CHECK (backfill_concurrency >= 1 AND backfill_concurrency <= 8)
|
||||||
|
);
|
||||||
|
INSERT INTO loudness_settings (id) VALUES (true) ON CONFLICT (id) DO NOTHING;
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
-- name: UpsertTrackLoudness :exec
|
||||||
|
-- Written when the backfill measures a track (#4995). Replaces the row
|
||||||
|
-- wholesale: a measurement of the old bytes has no standing once the file has
|
||||||
|
-- changed.
|
||||||
|
INSERT INTO track_loudness (
|
||||||
|
track_id, integrated_lufs, true_peak_dbtp, loudness_range_lu,
|
||||||
|
block_hist_start, block_hist, unreadable, analysis_version
|
||||||
|
) VALUES (
|
||||||
|
sqlc.arg(track_id), sqlc.narg(integrated_lufs), sqlc.narg(true_peak_dbtp),
|
||||||
|
sqlc.narg(loudness_range_lu), sqlc.narg(block_hist_start), sqlc.narg(block_hist),
|
||||||
|
sqlc.arg(unreadable), sqlc.arg(analysis_version)
|
||||||
|
)
|
||||||
|
ON CONFLICT (track_id) DO UPDATE SET
|
||||||
|
integrated_lufs = EXCLUDED.integrated_lufs,
|
||||||
|
true_peak_dbtp = EXCLUDED.true_peak_dbtp,
|
||||||
|
loudness_range_lu = EXCLUDED.loudness_range_lu,
|
||||||
|
block_hist_start = EXCLUDED.block_hist_start,
|
||||||
|
block_hist = EXCLUDED.block_hist,
|
||||||
|
unreadable = EXCLUDED.unreadable,
|
||||||
|
analysis_version = EXCLUDED.analysis_version,
|
||||||
|
analyzed_at = now();
|
||||||
|
|
||||||
|
-- name: DeleteTrackLoudness :exec
|
||||||
|
-- The scan saw new bytes at this path. The stored measurement describes the old
|
||||||
|
-- ones, so it goes, and the backfill measures the file again.
|
||||||
|
DELETE FROM track_loudness WHERE track_id = $1;
|
||||||
|
|
||||||
|
-- name: ListTracksNeedingLoudness :many
|
||||||
|
-- The backfill's work queue: tracks with no measurement, or one taken by an
|
||||||
|
-- older method. Keyset-paged on id so a pass visits each track at most once;
|
||||||
|
-- an inconclusive attempt writes no row, and without the cursor a file that
|
||||||
|
-- keeps timing out would be listed again straight away. Missing tracks are
|
||||||
|
-- skipped: there is no file to read.
|
||||||
|
SELECT t.id, t.file_path, t.duration_ms
|
||||||
|
FROM tracks t
|
||||||
|
LEFT JOIN track_loudness l ON l.track_id = t.id
|
||||||
|
WHERE t.missing_since IS NULL
|
||||||
|
AND (l.track_id IS NULL OR l.analysis_version < sqlc.arg(current_version))
|
||||||
|
AND t.id > sqlc.arg(after_id)
|
||||||
|
ORDER BY t.id
|
||||||
|
LIMIT sqlc.arg(batch_limit);
|
||||||
|
|
||||||
|
-- name: GetLoudnessCoverage :one
|
||||||
|
-- The admin gauge. measured + silent + unreadable + pending = total. Missing
|
||||||
|
-- tracks are excluded, or the gauge could never reach the end.
|
||||||
|
SELECT count(*)::bigint AS total,
|
||||||
|
count(*) FILTER (
|
||||||
|
WHERE l.analysis_version >= sqlc.arg(current_version)
|
||||||
|
AND l.integrated_lufs IS NOT NULL
|
||||||
|
)::bigint AS measured,
|
||||||
|
count(*) FILTER (
|
||||||
|
WHERE l.analysis_version >= sqlc.arg(current_version)
|
||||||
|
AND l.integrated_lufs IS NULL AND NOT l.unreadable
|
||||||
|
)::bigint AS silent,
|
||||||
|
count(*) FILTER (
|
||||||
|
WHERE l.analysis_version >= sqlc.arg(current_version)
|
||||||
|
AND l.unreadable
|
||||||
|
)::bigint AS unreadable,
|
||||||
|
count(*) FILTER (
|
||||||
|
WHERE l.track_id IS NULL OR l.analysis_version < sqlc.arg(current_version)
|
||||||
|
)::bigint AS pending
|
||||||
|
FROM tracks t
|
||||||
|
LEFT JOIN track_loudness l ON l.track_id = t.id
|
||||||
|
WHERE t.missing_since IS NULL;
|
||||||
|
|
||||||
|
-- name: GetLoudnessSettings :one
|
||||||
|
SELECT * FROM loudness_settings WHERE id = true;
|
||||||
|
|
||||||
|
-- name: UpdateLoudnessSettings :one
|
||||||
|
-- Whole-row write from the admin card; migration 0065's CHECK is the backstop
|
||||||
|
-- behind the service's own validation.
|
||||||
|
UPDATE loudness_settings
|
||||||
|
SET enabled = sqlc.arg(enabled),
|
||||||
|
backfill_concurrency = sqlc.arg(backfill_concurrency),
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = true
|
||||||
|
RETURNING *;
|
||||||
@@ -91,6 +91,7 @@ var dataTables = []string{
|
|||||||
"duplicate_groups",
|
"duplicate_groups",
|
||||||
"duplicate_sweeps",
|
"duplicate_sweeps",
|
||||||
"track_fingerprints", // M400
|
"track_fingerprints", // M400
|
||||||
|
"track_loudness", // M464
|
||||||
"tracks",
|
"tracks",
|
||||||
"albums",
|
"albums",
|
||||||
"artists",
|
"artists",
|
||||||
@@ -141,4 +142,11 @@ func ResetDB(t *testing.T, pool *pgxpool.Pool) {
|
|||||||
); err != nil {
|
); err != nil {
|
||||||
t.Fatalf("dbtest.ResetDB reset fingerprint settings: %v", err)
|
t.Fatalf("dbtest.ResetDB reset fingerprint settings: %v", err)
|
||||||
}
|
}
|
||||||
|
// Loudness analysis settings (M464 #4995), reset the same way.
|
||||||
|
if _, err := pool.Exec(ctx, `
|
||||||
|
UPDATE loudness_settings
|
||||||
|
SET enabled = DEFAULT, backfill_concurrency = DEFAULT, updated_at = DEFAULT`,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("dbtest.ResetDB reset loudness settings: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,392 @@
|
|||||||
|
package library
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"math"
|
||||||
|
"os/exec"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Loudness analysis (M464 #4995).
|
||||||
|
//
|
||||||
|
// Every track is measured with ffmpeg's EBU R128 filter, which reports the
|
||||||
|
// ITU-R BS.1770 values loudness normalization works from:
|
||||||
|
//
|
||||||
|
// integrated loudness the gated, K-weighted average loudness of the whole
|
||||||
|
// track, in LUFS. A client levels a track by playing it
|
||||||
|
// at (target - integrated) dB.
|
||||||
|
// true peak the highest inter-sample peak, in dBTP. It bounds how
|
||||||
|
// far a quiet track can be raised before it clips.
|
||||||
|
// loudness range how much the loudness moves within the track.
|
||||||
|
//
|
||||||
|
// Tags are not trusted: ReplayGain values in the wild are written against four
|
||||||
|
// different reference levels, and most files have none.
|
||||||
|
//
|
||||||
|
// The filter also logs the loudness of every 400 ms gating block (one every
|
||||||
|
// 100 ms). Those are kept as a histogram, because an album's loudness is the
|
||||||
|
// gated loudness of every block on the album, not an average of its tracks'
|
||||||
|
// values. With the histograms stored, album loudness is exact and needs no
|
||||||
|
// second decode (#4996).
|
||||||
|
|
||||||
|
// loudnessVersion stamps how a track_loudness row was measured. Bump it when
|
||||||
|
// the measurement changes (the filter's options, the histogram's bins) and the
|
||||||
|
// backfill measures every row below it again.
|
||||||
|
const loudnessVersion int16 = 1
|
||||||
|
|
||||||
|
// Unlike a fingerprint, the analysis decodes the whole file, so a fixed
|
||||||
|
// deadline would either cut off a long mix or be useless for a three-minute
|
||||||
|
// song. The deadline is a base plus the track's length at loudnessMinSpeed:
|
||||||
|
// a decode slower than that is a stall, not a big file.
|
||||||
|
const (
|
||||||
|
loudnessBaseTimeout = 2 * time.Minute
|
||||||
|
loudnessMinSpeed = 4
|
||||||
|
)
|
||||||
|
|
||||||
|
// errLoudnessTimeout marks an analysis that ran out of time: a fact about the
|
||||||
|
// mount, not about the file.
|
||||||
|
var errLoudnessTimeout = errors.New("loudness analysis timed out")
|
||||||
|
|
||||||
|
// The block histogram's bins: 0.1 LU wide (the precision ffmpeg prints) from
|
||||||
|
// the -70 LUFS absolute gate up to +10 LUFS. Blocks below the gate do not take
|
||||||
|
// part in gating at all, so they are not counted; anything above the top bin,
|
||||||
|
// which only clipped noise reaches, is counted in it.
|
||||||
|
const (
|
||||||
|
loudnessHistFloor = -70.0
|
||||||
|
loudnessHistPerLU = 10
|
||||||
|
loudnessHistBins = 800
|
||||||
|
loudnessStderrTail = 20 // lines kept for an error message
|
||||||
|
)
|
||||||
|
|
||||||
|
// histogramCrossCheckLU is how far the loudness recomputed from the histogram
|
||||||
|
// may stray from ffmpeg's own figure before it is logged. They agree to a few
|
||||||
|
// hundredths when the log means what the parser assumes, so a larger gap says
|
||||||
|
// ffmpeg's output has changed underneath us.
|
||||||
|
const histogramCrossCheckLU = 0.3
|
||||||
|
|
||||||
|
// ebur128Args measures the file's first audio stream.
|
||||||
|
//
|
||||||
|
// peak=true asks for true peak (4x oversampled) rather than sample peak, which
|
||||||
|
// misses the inter-sample overs a boosted track would clip on. dualmono=true
|
||||||
|
// measures a mono file as if played on both speakers of a stereo pair, which is
|
||||||
|
// how it is heard; measured as one channel it would read 3 LU quiet and be
|
||||||
|
// boosted too far. framelog=info makes the per-block lines print at the log
|
||||||
|
// level asked for here, independent of ffmpeg's default.
|
||||||
|
func ebur128Args(path string) []string {
|
||||||
|
return []string{
|
||||||
|
"-hide_banner", "-nostdin", "-nostats",
|
||||||
|
"-loglevel", "info",
|
||||||
|
"-i", path,
|
||||||
|
"-map", "0:a:0",
|
||||||
|
"-af", "ebur128=peak=true:dualmono=true:framelog=info",
|
||||||
|
"-f", "null", "-",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// loudnessTimeout is the deadline for a track of durationMs. An unknown length
|
||||||
|
// (0) gets the base alone, which covers an ordinary song.
|
||||||
|
func loudnessTimeout(durationMs int32) time.Duration {
|
||||||
|
return loudnessBaseTimeout + time.Duration(max(durationMs, 0))*time.Millisecond/loudnessMinSpeed
|
||||||
|
}
|
||||||
|
|
||||||
|
// loudnessResult is one attempt at measuring a track.
|
||||||
|
type loudnessResult struct {
|
||||||
|
// integratedLUFS is nil when no block passed the gates: silence, or a
|
||||||
|
// file shorter than one 400 ms block.
|
||||||
|
integratedLUFS *float32
|
||||||
|
truePeakDBTP *float32
|
||||||
|
rangeLU *float32
|
||||||
|
hist blockHistogram
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// inconclusive reports whether the attempt failed for a reason that says
|
||||||
|
// nothing about the file. Such a result is never stored: stamped at the current
|
||||||
|
// version it would read as "this file cannot be measured", and the backfill
|
||||||
|
// would never try it again.
|
||||||
|
func (r loudnessResult) inconclusive() bool {
|
||||||
|
return isInconclusive(r.err) || errors.Is(r.err, errLoudnessTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
// blockHistogram counts gating blocks per 0.1 LU bin, trimmed to the occupied
|
||||||
|
// range: counts[i] is the number of blocks measuring
|
||||||
|
// loudnessHistFloor + (start+i)/loudnessHistPerLU LUFS.
|
||||||
|
type blockHistogram struct {
|
||||||
|
start int16
|
||||||
|
counts []int32
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h blockHistogram) empty() bool { return len(h.counts) == 0 }
|
||||||
|
|
||||||
|
// gatedLoudness applies BS.1770's two gates to the histogram and returns the
|
||||||
|
// integrated loudness of what passes. Every counted block is already above the
|
||||||
|
// absolute gate; the relative gate drops blocks more than 10 LU below the
|
||||||
|
// loudness of the blocks that passed it. ok is false when nothing passes.
|
||||||
|
//
|
||||||
|
// The same function measures an album, over the sum of its tracks'
|
||||||
|
// histograms (#4996).
|
||||||
|
func (h blockHistogram) gatedLoudness() (lufs float64, ok bool) {
|
||||||
|
energy := func(i int) float64 {
|
||||||
|
l := loudnessHistFloor + float64(int(h.start)+i)/loudnessHistPerLU
|
||||||
|
return math.Pow(10, (l+0.691)/10)
|
||||||
|
}
|
||||||
|
mean := func(threshold float64) (float64, bool) {
|
||||||
|
var sum, n float64
|
||||||
|
for i, c := range h.counts {
|
||||||
|
if c == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if loudnessHistFloor+float64(int(h.start)+i)/loudnessHistPerLU < threshold {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sum += float64(c) * energy(i)
|
||||||
|
n += float64(c)
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return sum / n, true
|
||||||
|
}
|
||||||
|
ungated, ok := mean(math.Inf(-1))
|
||||||
|
if !ok {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
relative := -0.691 + 10*math.Log10(ungated) - 10
|
||||||
|
gated, ok := mean(relative)
|
||||||
|
if !ok {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return -0.691 + 10*math.Log10(gated), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// computeLoudness measures the file at path. durationMs sets the deadline.
|
||||||
|
func computeLoudness(ctx context.Context, path string, durationMs int32) loudnessResult {
|
||||||
|
timeout := loudnessTimeout(durationMs)
|
||||||
|
runCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(runCtx, "ffmpeg", ebur128Args(path)...)
|
||||||
|
cmd.WaitDelay = fingerprintWaitDelay
|
||||||
|
stderr, err := cmd.StderrPipe()
|
||||||
|
if err != nil {
|
||||||
|
return loudnessResult{err: fmt.Errorf("ffmpeg: %w", err)}
|
||||||
|
}
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return loudnessResult{err: fmt.Errorf("ffmpeg: %w", err)}
|
||||||
|
}
|
||||||
|
// The per-block log runs to ten lines a second of audio, about 12 MB for a
|
||||||
|
// two-hour mix, so it is parsed as it streams rather than buffered.
|
||||||
|
p := newEbur128Parser()
|
||||||
|
p.consume(stderr)
|
||||||
|
waitErr := cmd.Wait()
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case ctx.Err() != nil:
|
||||||
|
// The caller gave up. Report that rather than the kill it caused, so it
|
||||||
|
// is never mistaken for a verdict on the file.
|
||||||
|
return loudnessResult{err: fmt.Errorf("ffmpeg: %w", ctx.Err())}
|
||||||
|
case errors.Is(runCtx.Err(), context.DeadlineExceeded):
|
||||||
|
return loudnessResult{err: fmt.Errorf("ffmpeg: no result within %s: %w", timeout, errLoudnessTimeout)}
|
||||||
|
case waitErr != nil:
|
||||||
|
var exitErr *exec.ExitError
|
||||||
|
if errors.As(waitErr, &exitErr) {
|
||||||
|
return loudnessResult{err: fmt.Errorf("ffmpeg exited %d: %s", exitErr.ExitCode(), p.tail())}
|
||||||
|
}
|
||||||
|
return loudnessResult{err: fmt.Errorf("ffmpeg: %w", waitErr)}
|
||||||
|
}
|
||||||
|
return p.result()
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
// A per-block line: "[Parsed_ebur128_0 @ 0x…] t: 2.49998 TARGET:-23 LUFS
|
||||||
|
// M: -31.1 S:-120.7 I: -31.1 LUFS ...". M is the 400 ms block just ended.
|
||||||
|
ebur128BlockRe = regexp.MustCompile(`\bt:\s*\S+\s+TARGET:.*?\bM:\s*(-?(?:\d+(?:\.\d+)?|inf))`)
|
||||||
|
// The summary's values, each on a line of its own after "Summary:".
|
||||||
|
ebur128SummaryRe = regexp.MustCompile(`^(I|LRA|Peak):\s+(-?(?:\d+(?:\.\d+)?|inf))\s+(?:LUFS|LU|dBFS)$`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// ebur128Parser reads the filter's log: the per-block lines into the
|
||||||
|
// histogram, and the closing summary.
|
||||||
|
type ebur128Parser struct {
|
||||||
|
bins [loudnessHistBins]int32
|
||||||
|
blocks int
|
||||||
|
inSummary bool
|
||||||
|
summary map[string]string
|
||||||
|
lastLines []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newEbur128Parser() *ebur128Parser {
|
||||||
|
return &ebur128Parser{summary: map[string]string{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ebur128Parser) consume(r io.Reader) {
|
||||||
|
sc := bufio.NewScanner(r)
|
||||||
|
sc.Buffer(make([]byte, 0, 4096), 64*1024)
|
||||||
|
for sc.Scan() {
|
||||||
|
p.line(sc.Text())
|
||||||
|
}
|
||||||
|
// A line past the buffer (not something ffmpeg prints) stops the scanner;
|
||||||
|
// drain the rest so ffmpeg is never blocked writing to a full pipe.
|
||||||
|
_, _ = io.Copy(io.Discard, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ebur128Parser) line(raw string) {
|
||||||
|
line := strings.TrimSpace(strings.TrimRight(raw, "\r"))
|
||||||
|
if line == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(p.lastLines) == loudnessStderrTail {
|
||||||
|
p.lastLines = p.lastLines[1:]
|
||||||
|
}
|
||||||
|
p.lastLines = append(p.lastLines, line)
|
||||||
|
|
||||||
|
if strings.HasSuffix(line, "Summary:") {
|
||||||
|
p.inSummary = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if p.inSummary {
|
||||||
|
if m := ebur128SummaryRe.FindStringSubmatch(line); m != nil {
|
||||||
|
p.summary[m[1]] = m[2]
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m := ebur128BlockRe.FindStringSubmatch(line)
|
||||||
|
if m == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v, err := strconv.ParseFloat(m[1], 64)
|
||||||
|
if err != nil || v < loudnessHistFloor {
|
||||||
|
// -inf, or below the absolute gate: such a block takes no part in
|
||||||
|
// gating.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bin := int(math.Round((v - loudnessHistFloor) * loudnessHistPerLU))
|
||||||
|
p.bins[min(bin, loudnessHistBins-1)]++
|
||||||
|
p.blocks++
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ebur128Parser) tail() string {
|
||||||
|
return strings.Join(p.lastLines, " | ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ebur128Parser) histogram() blockHistogram {
|
||||||
|
if p.blocks == 0 {
|
||||||
|
return blockHistogram{}
|
||||||
|
}
|
||||||
|
first, last := 0, loudnessHistBins-1
|
||||||
|
for p.bins[first] == 0 {
|
||||||
|
first++
|
||||||
|
}
|
||||||
|
for p.bins[last] == 0 {
|
||||||
|
last--
|
||||||
|
}
|
||||||
|
counts := make([]int32, last-first+1)
|
||||||
|
copy(counts, p.bins[first:last+1])
|
||||||
|
return blockHistogram{start: int16(first), counts: counts}
|
||||||
|
}
|
||||||
|
|
||||||
|
// result turns a clean exit into a measurement. A run with no summary means
|
||||||
|
// ffmpeg decoded nothing it could measure, which is a verdict on the file.
|
||||||
|
func (p *ebur128Parser) result() loudnessResult {
|
||||||
|
integrated, ok := p.summary["I"]
|
||||||
|
if !ok {
|
||||||
|
return loudnessResult{err: fmt.Errorf("ffmpeg printed no loudness summary: %s", p.tail())}
|
||||||
|
}
|
||||||
|
r := loudnessResult{
|
||||||
|
hist: p.histogram(),
|
||||||
|
truePeakDBTP: parseLoudnessValue(p.summary["Peak"]),
|
||||||
|
rangeLU: parseLoudnessValue(p.summary["LRA"]),
|
||||||
|
}
|
||||||
|
// ffmpeg reports -70.0 when no block passed the gate. The empty histogram
|
||||||
|
// says the same thing directly.
|
||||||
|
if !r.hist.empty() {
|
||||||
|
r.integratedLUFS = parseLoudnessValue(integrated)
|
||||||
|
}
|
||||||
|
if r.integratedLUFS == nil {
|
||||||
|
r.rangeLU = nil
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseLoudnessValue reads one summary figure; -inf and anything unreadable
|
||||||
|
// come back nil.
|
||||||
|
func parseLoudnessValue(s string) *float32 {
|
||||||
|
v, err := strconv.ParseFloat(s, 32)
|
||||||
|
if err != nil || math.IsInf(v, 0) || math.IsNaN(v) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
f := float32(v)
|
||||||
|
return &f
|
||||||
|
}
|
||||||
|
|
||||||
|
// loudnessOutcome is what storeLoudness did with one attempt.
|
||||||
|
type loudnessOutcome int
|
||||||
|
|
||||||
|
const (
|
||||||
|
loudnessMeasured loudnessOutcome = iota // integrated loudness stored
|
||||||
|
loudnessSilent // read fine; no block above the gate
|
||||||
|
loudnessUnreadable // ffmpeg could not decode the file
|
||||||
|
loudnessInconclusive // nothing stored; worth trying again
|
||||||
|
loudnessStoreFailed // the write itself failed
|
||||||
|
)
|
||||||
|
|
||||||
|
// storeLoudness records one attempt. It never fails its caller: an unmeasured
|
||||||
|
// track simply plays without a gain adjustment.
|
||||||
|
//
|
||||||
|
// An inconclusive attempt leaves any existing row alone. The scan deletes a
|
||||||
|
// row when its file changes, so a row still here describes these bytes, and a
|
||||||
|
// value from an older method is a better gain than none until it is redone.
|
||||||
|
func storeLoudness(
|
||||||
|
ctx context.Context, q *dbq.Queries, logger *slog.Logger,
|
||||||
|
trackID pgtype.UUID, path string, r loudnessResult,
|
||||||
|
) loudnessOutcome {
|
||||||
|
if r.err != nil {
|
||||||
|
logger.Warn("loudness: analysis failed", "path", path, "err", r.err)
|
||||||
|
if r.inconclusive() {
|
||||||
|
return loudnessInconclusive
|
||||||
|
}
|
||||||
|
}
|
||||||
|
params := dbq.UpsertTrackLoudnessParams{
|
||||||
|
TrackID: trackID,
|
||||||
|
Unreadable: r.err != nil,
|
||||||
|
AnalysisVersion: loudnessVersion,
|
||||||
|
}
|
||||||
|
outcome := loudnessUnreadable
|
||||||
|
if r.err == nil {
|
||||||
|
outcome = loudnessSilent
|
||||||
|
params.IntegratedLufs = r.integratedLUFS
|
||||||
|
params.TruePeakDbtp = r.truePeakDBTP
|
||||||
|
params.LoudnessRangeLu = r.rangeLU
|
||||||
|
if !r.hist.empty() {
|
||||||
|
start := r.hist.start
|
||||||
|
params.BlockHistStart = &start
|
||||||
|
params.BlockHist = r.hist.counts
|
||||||
|
}
|
||||||
|
if r.integratedLUFS != nil {
|
||||||
|
outcome = loudnessMeasured
|
||||||
|
if got, ok := r.hist.gatedLoudness(); ok && math.Abs(got-float64(*r.integratedLUFS)) > histogramCrossCheckLU {
|
||||||
|
// Stored regardless: ffmpeg's own figure is the track's
|
||||||
|
// loudness. But album loudness is computed from the
|
||||||
|
// histogram, and this says it would be wrong.
|
||||||
|
logger.Warn("loudness: block histogram disagrees with ffmpeg's integrated loudness",
|
||||||
|
"path", path, "ffmpeg_lufs", *r.integratedLUFS, "histogram_lufs", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := q.UpsertTrackLoudness(ctx, params); err != nil {
|
||||||
|
logger.Warn("loudness: storing measurement failed", "path", path, "err", err)
|
||||||
|
return loudnessStoreFailed
|
||||||
|
}
|
||||||
|
return outcome
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
package library
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Loudness backfill (M464 #4995).
|
||||||
|
//
|
||||||
|
// Every track is measured here, the new ones included: the scan only deletes a
|
||||||
|
// changed file's measurement (see scanFile), and this worker measures it again.
|
||||||
|
// Measuring inline in the scan was the first plan and was dropped, because the
|
||||||
|
// analysis decodes the whole file. Added to the scan, a large import would run
|
||||||
|
// several times longer and could pass StuckScanThreshold (1h), at which point
|
||||||
|
// the run is reaped and a second scan started beside it. The fingerprint
|
||||||
|
// backfill is a worker of its own for the same reason.
|
||||||
|
//
|
||||||
|
// Until a track is measured it plays with no gain adjustment, which is how
|
||||||
|
// every track played before normalization existed.
|
||||||
|
|
||||||
|
// loudnessBackfillTick is how often the worker looks for work. Shorter than
|
||||||
|
// the fingerprint backfill's hour, because a new track plays unleveled until it
|
||||||
|
// is measured; once the library has caught up, a tick is one indexed query.
|
||||||
|
const loudnessBackfillTick = 10 * time.Minute
|
||||||
|
|
||||||
|
// loudnessBackfillBatch is how many tracks one query hands the worker.
|
||||||
|
const loudnessBackfillBatch = 50
|
||||||
|
|
||||||
|
// loudnessBackfillConcurrency is the shipped value of the concurrency setting.
|
||||||
|
// Low for the reason fingerprinting's is: each analysis is a full decode,
|
||||||
|
// competing with playback transcoding and streaming.
|
||||||
|
const loudnessBackfillConcurrency = 2
|
||||||
|
|
||||||
|
// BackfillLoudnessResult tallies one pass.
|
||||||
|
type BackfillLoudnessResult struct {
|
||||||
|
Processed int
|
||||||
|
Measured int
|
||||||
|
Silent int // read fine, no block above the gate (settled)
|
||||||
|
Unreadable int // ffmpeg could not decode the file (settled)
|
||||||
|
Inconclusive int // nothing stored; tried again on a later pass
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *BackfillLoudnessResult) add(o loudnessOutcome) {
|
||||||
|
r.Processed++
|
||||||
|
switch o {
|
||||||
|
case loudnessMeasured:
|
||||||
|
r.Measured++
|
||||||
|
case loudnessSilent:
|
||||||
|
r.Silent++
|
||||||
|
case loudnessUnreadable:
|
||||||
|
r.Unreadable++
|
||||||
|
default:
|
||||||
|
r.Inconclusive++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoudnessBackfillWorker measures every track that has no current measurement.
|
||||||
|
type LoudnessBackfillWorker struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
logger *slog.Logger
|
||||||
|
settings *LoudnessSettingsService
|
||||||
|
tick time.Duration
|
||||||
|
batch int32
|
||||||
|
// analyze is a field so an integration test pins which tracks a pass
|
||||||
|
// touches, not what ffmpeg prints.
|
||||||
|
analyze func(ctx context.Context, path string, durationMs int32) loudnessResult
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLoudnessBackfillWorker builds a worker with the production cadence.
|
||||||
|
// settings is shared with the admin API; nil runs on defaults.
|
||||||
|
func NewLoudnessBackfillWorker(
|
||||||
|
pool *pgxpool.Pool, logger *slog.Logger, settings *LoudnessSettingsService,
|
||||||
|
) *LoudnessBackfillWorker {
|
||||||
|
return &LoudnessBackfillWorker{
|
||||||
|
pool: pool,
|
||||||
|
logger: logger,
|
||||||
|
settings: settings,
|
||||||
|
tick: loudnessBackfillTick,
|
||||||
|
batch: loudnessBackfillBatch,
|
||||||
|
analyze: computeLoudness,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run blocks until ctx is cancelled: one pass at start, then one per tick.
|
||||||
|
func (w *LoudnessBackfillWorker) Run(ctx context.Context) {
|
||||||
|
w.runOnce(ctx)
|
||||||
|
t := time.NewTicker(w.tick)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
w.runOnce(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runOnce contains a pass so that nothing it does (an error, a panic) can stop
|
||||||
|
// the next tick from firing (rule 157).
|
||||||
|
func (w *LoudnessBackfillWorker) runOnce(ctx context.Context) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
w.logger.Error("loudness backfill: pass panicked", "panic", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
res, err := w.pass(ctx)
|
||||||
|
if err != nil && ctx.Err() == nil {
|
||||||
|
w.logger.Warn("loudness backfill: pass failed", "err", err, "processed", res.Processed)
|
||||||
|
}
|
||||||
|
if res.Processed > 0 {
|
||||||
|
w.logger.Info("loudness backfill: pass complete",
|
||||||
|
"processed", res.Processed, "measured", res.Measured, "silent", res.Silent,
|
||||||
|
"unreadable", res.Unreadable, "inconclusive", res.Inconclusive)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pass walks every track needing a measurement once, keyset-paged on id. The
|
||||||
|
// cursor is what lets a pass end: an inconclusive attempt writes no row, so a
|
||||||
|
// file that keeps timing out would otherwise be listed again immediately.
|
||||||
|
// Settings are read before every batch, so switching analysis off ends the
|
||||||
|
// pass and a new concurrency applies to the next batch.
|
||||||
|
func (w *LoudnessBackfillWorker) pass(ctx context.Context) (BackfillLoudnessResult, error) {
|
||||||
|
q := dbq.New(w.pool)
|
||||||
|
var (
|
||||||
|
res BackfillLoudnessResult
|
||||||
|
mu sync.Mutex
|
||||||
|
)
|
||||||
|
// The all-zero uuid sorts before every real id. Valid must be true: a NULL
|
||||||
|
// cursor would make "id > NULL" match nothing and every pass a no-op.
|
||||||
|
after := pgtype.UUID{Valid: true}
|
||||||
|
for {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
cfg := w.settings.Get()
|
||||||
|
if !cfg.Enabled {
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
rows, err := q.ListTracksNeedingLoudness(ctx, dbq.ListTracksNeedingLoudnessParams{
|
||||||
|
CurrentVersion: loudnessVersion,
|
||||||
|
AfterID: after,
|
||||||
|
BatchLimit: w.batch,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return res, fmt.Errorf("list tracks needing loudness: %w", err)
|
||||||
|
}
|
||||||
|
if len(rows) == 0 {
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
sem := make(chan struct{}, max(1, int(cfg.BackfillConcurrency)))
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for _, row := range rows {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
sem <- struct{}{}
|
||||||
|
wg.Add(1)
|
||||||
|
go func(row dbq.ListTracksNeedingLoudnessRow) {
|
||||||
|
defer wg.Done()
|
||||||
|
defer func() { <-sem }()
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
w.logger.Error("loudness backfill: track panicked", "path", row.FilePath, "panic", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
outcome := storeLoudness(ctx, q, w.logger, row.ID, row.FilePath,
|
||||||
|
w.analyzeFile(ctx, row.FilePath, row.DurationMs))
|
||||||
|
mu.Lock()
|
||||||
|
res.add(outcome)
|
||||||
|
mu.Unlock()
|
||||||
|
}(row)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
after = rows[len(rows)-1].ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *LoudnessBackfillWorker) analyzeFile(ctx context.Context, path string, durationMs int32) loudnessResult {
|
||||||
|
if w.analyze == nil {
|
||||||
|
return computeLoudness(ctx, path, durationMs)
|
||||||
|
}
|
||||||
|
return w.analyze(ctx, path, durationMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoudnessCoverage reports how much of the library carries a current
|
||||||
|
// measurement, for the admin gauge. It lives beside the backfill so the
|
||||||
|
// version it counts against is the one the backfill writes.
|
||||||
|
func LoudnessCoverage(ctx context.Context, pool *pgxpool.Pool) (dbq.GetLoudnessCoverageRow, error) {
|
||||||
|
return dbq.New(pool).GetLoudnessCoverage(ctx, loudnessVersion)
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
package library
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestLoudnessBackfill_Integration pins which tracks a pass measures, what it
|
||||||
|
// stores for each kind of result, that a pass ends, and that the gauge counts
|
||||||
|
// what the passes wrote.
|
||||||
|
func TestLoudnessBackfill_Integration(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
q := dbq.New(pool)
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
_, album, artist := seedTrack(t, pool, filepath.Join(dir, "unmeasured.mp3"))
|
||||||
|
addTrack := func(name string) dbq.Track {
|
||||||
|
t.Helper()
|
||||||
|
tr, err := q.UpsertTrack(ctx, dbq.UpsertTrackParams{
|
||||||
|
Title: name, AlbumID: album.ID, ArtistID: artist.ID,
|
||||||
|
DurationMs: 180000, FilePath: filepath.Join(dir, name+".mp3"), FileSize: 100, FileFormat: "mp3",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("track %s: %v", name, err)
|
||||||
|
}
|
||||||
|
return tr
|
||||||
|
}
|
||||||
|
lufs := func(v float32) *float32 { return &v }
|
||||||
|
current := addTrack("current")
|
||||||
|
stale := addTrack("stale")
|
||||||
|
missing := addTrack("missing")
|
||||||
|
for _, seed := range []struct {
|
||||||
|
track dbq.Track
|
||||||
|
version int16
|
||||||
|
}{
|
||||||
|
{current, loudnessVersion},
|
||||||
|
{stale, loudnessVersion - 1},
|
||||||
|
} {
|
||||||
|
if err := q.UpsertTrackLoudness(ctx, dbq.UpsertTrackLoudnessParams{
|
||||||
|
TrackID: seed.track.ID, IntegratedLufs: lufs(-9), AnalysisVersion: seed.version,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("seed loudness: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := pool.Exec(ctx, "UPDATE tracks SET missing_since = now() WHERE id = $1", missing.ID); err != nil {
|
||||||
|
t.Fatalf("mark missing: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
settings, err := NewLoudnessSettingsService(ctx, pool)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("loudness settings: %v", err)
|
||||||
|
}
|
||||||
|
w := NewLoudnessBackfillWorker(pool, slog.New(slog.NewTextHandler(io.Discard, nil)), settings)
|
||||||
|
// A batch of one forces the keyset cursor across several queries in a pass.
|
||||||
|
w.batch = 1
|
||||||
|
var mu sync.Mutex
|
||||||
|
calls := map[string]int{}
|
||||||
|
durations := map[string]int32{}
|
||||||
|
start := int16(500)
|
||||||
|
w.analyze = func(_ context.Context, path string, durationMs int32) loudnessResult {
|
||||||
|
name := filepath.Base(path)
|
||||||
|
mu.Lock()
|
||||||
|
calls[name]++
|
||||||
|
durations[name] = durationMs
|
||||||
|
mu.Unlock()
|
||||||
|
switch name {
|
||||||
|
case "stall.mp3":
|
||||||
|
return loudnessResult{err: fmt.Errorf("ffmpeg: %w", errLoudnessTimeout)}
|
||||||
|
case "corrupt.mp3":
|
||||||
|
return loudnessResult{err: errors.New("ffmpeg exited 1: invalid data")}
|
||||||
|
case "silent.mp3":
|
||||||
|
return loudnessResult{}
|
||||||
|
default:
|
||||||
|
return loudnessResult{
|
||||||
|
integratedLUFS: lufs(-12.5), truePeakDBTP: lufs(-0.4), rangeLU: lufs(6),
|
||||||
|
hist: blockHistogram{start: start, counts: []int32{3, 0, 7}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
callCount := func(name string) int {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
return calls[name]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Only the unmeasured track and the stale one are measured, never the
|
||||||
|
// current one or the missing one, and the track's length reaches the
|
||||||
|
// analyzer (it sets the deadline).
|
||||||
|
res, err := w.pass(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first pass: %v", err)
|
||||||
|
}
|
||||||
|
if res.Processed != 2 || res.Measured != 2 {
|
||||||
|
t.Fatalf("first pass = %+v, want 2 processed, 2 measured", res)
|
||||||
|
}
|
||||||
|
for name, want := range map[string]int{
|
||||||
|
"unmeasured.mp3": 1, "stale.mp3": 1, "current.mp3": 0, "missing.mp3": 0,
|
||||||
|
} {
|
||||||
|
if got := callCount(name); got != want {
|
||||||
|
t.Errorf("%s measured %d times, want %d", name, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if durations["stale.mp3"] != 180000 {
|
||||||
|
t.Errorf("analyzer got duration %d for stale.mp3, want 180000", durations["stale.mp3"])
|
||||||
|
}
|
||||||
|
var (
|
||||||
|
gotLUFS, gotPeak *float32
|
||||||
|
gotStart *int16
|
||||||
|
gotHist []int32
|
||||||
|
gotVersion int16
|
||||||
|
)
|
||||||
|
if err := pool.QueryRow(ctx, `SELECT integrated_lufs, true_peak_dbtp, block_hist_start, block_hist, analysis_version
|
||||||
|
FROM track_loudness WHERE track_id = $1`, stale.ID).
|
||||||
|
Scan(&gotLUFS, &gotPeak, &gotStart, &gotHist, &gotVersion); err != nil {
|
||||||
|
t.Fatalf("read stale row back: %v", err)
|
||||||
|
}
|
||||||
|
if gotLUFS == nil || *gotLUFS != -12.5 || gotPeak == nil || *gotPeak != -0.4 ||
|
||||||
|
gotStart == nil || *gotStart != start || len(gotHist) != 3 || gotHist[2] != 7 ||
|
||||||
|
gotVersion != loudnessVersion {
|
||||||
|
t.Errorf("stale row after re-measuring = lufs %v peak %v hist %v@%v version %d",
|
||||||
|
gotLUFS, gotPeak, gotHist, gotStart, gotVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. A pass after a complete one is a no-op.
|
||||||
|
res, err = w.pass(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("second pass: %v", err)
|
||||||
|
}
|
||||||
|
if res.Processed != 0 {
|
||||||
|
t.Fatalf("second pass processed %d tracks, want 0", res.Processed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. A stall is tried once and the pass ends; silence and a corrupt file are
|
||||||
|
// verdicts, stored and not tried again.
|
||||||
|
addTrack("stall")
|
||||||
|
addTrack("corrupt")
|
||||||
|
silent := addTrack("silent")
|
||||||
|
res, err = w.pass(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("third pass: %v", err)
|
||||||
|
}
|
||||||
|
if res.Processed != 3 || res.Inconclusive != 1 || res.Unreadable != 1 || res.Silent != 1 {
|
||||||
|
t.Fatalf("third pass = %+v, want 3 processed: 1 inconclusive, 1 unreadable, 1 silent", res)
|
||||||
|
}
|
||||||
|
if got := callCount("stall.mp3"); got != 1 {
|
||||||
|
t.Fatalf("stalling file tried %d times in one pass, want exactly 1", got)
|
||||||
|
}
|
||||||
|
var silentHist []int32
|
||||||
|
var silentUnreadable bool
|
||||||
|
if err := pool.QueryRow(ctx, "SELECT block_hist, unreadable FROM track_loudness WHERE track_id = $1",
|
||||||
|
silent.ID).Scan(&silentHist, &silentUnreadable); err != nil {
|
||||||
|
t.Fatalf("read silent row: %v", err)
|
||||||
|
}
|
||||||
|
if silentHist != nil || silentUnreadable {
|
||||||
|
t.Errorf("silent row = hist %v unreadable %v, want no histogram and readable", silentHist, silentUnreadable)
|
||||||
|
}
|
||||||
|
res, err = w.pass(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("fourth pass: %v", err)
|
||||||
|
}
|
||||||
|
if res.Processed != 1 || callCount("stall.mp3") != 2 || callCount("corrupt.mp3") != 1 {
|
||||||
|
t.Fatalf("fourth pass = %+v; want only the stalled file retried", res)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. The gauge counts what the passes wrote, and its buckets add up. Six
|
||||||
|
// present tracks: unmeasured, current, stale, stall, corrupt, silent.
|
||||||
|
cov, err := LoudnessCoverage(ctx, pool)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("coverage: %v", err)
|
||||||
|
}
|
||||||
|
if cov.Total != 6 || cov.Measured != 3 || cov.Silent != 1 || cov.Unreadable != 1 || cov.Pending != 1 {
|
||||||
|
t.Errorf("coverage = %+v, want total 6, measured 3, silent 1, unreadable 1, pending 1", cov)
|
||||||
|
}
|
||||||
|
if cov.Measured+cov.Silent+cov.Unreadable+cov.Pending != cov.Total {
|
||||||
|
t.Errorf("coverage buckets %+v do not sum to the total", cov)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. A changed file loses its measurement, so it is measured again.
|
||||||
|
if err := q.DeleteTrackLoudness(ctx, current.ID); err != nil {
|
||||||
|
t.Fatalf("delete loudness: %v", err)
|
||||||
|
}
|
||||||
|
// 6. Switched off, the backfill does nothing, even with work waiting.
|
||||||
|
off := DefaultLoudnessSettings
|
||||||
|
off.Enabled = false
|
||||||
|
if _, err := settings.Set(ctx, off); err != nil {
|
||||||
|
t.Fatalf("switch analysis off: %v", err)
|
||||||
|
}
|
||||||
|
res, err = w.pass(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("pass with analysis off: %v", err)
|
||||||
|
}
|
||||||
|
if res.Processed != 0 || callCount("current.mp3") != 0 {
|
||||||
|
t.Fatalf("pass with analysis off = %+v, want nothing done", res)
|
||||||
|
}
|
||||||
|
if _, err := settings.Set(ctx, DefaultLoudnessSettings); err != nil {
|
||||||
|
t.Fatalf("switch analysis on: %v", err)
|
||||||
|
}
|
||||||
|
res, err = w.pass(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("pass after switching back on: %v", err)
|
||||||
|
}
|
||||||
|
if callCount("current.mp3") != 1 {
|
||||||
|
t.Fatalf("changed file measured %d times after switching back on (pass %+v), want 1",
|
||||||
|
callCount("current.mp3"), res)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Go defaults must match the migration's, or a database that cannot be
|
||||||
|
// read would analyze differently from a fresh install.
|
||||||
|
func TestLoudnessSettings_DefaultsMatchMigration(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
s, err := NewLoudnessSettingsService(context.Background(), pool)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load: %v", err)
|
||||||
|
}
|
||||||
|
got := s.Get()
|
||||||
|
got.UpdatedAt = DefaultLoudnessSettings.UpdatedAt
|
||||||
|
if got != DefaultLoudnessSettings {
|
||||||
|
t.Errorf("migration defaults = %+v, Go defaults = %+v", got, DefaultLoudnessSettings)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
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
|
||||||
|
// UpdatedAt is set by the database; ignored by Set.
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultLoudnessSettings mirrors migration 0065'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,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
})
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loudnessSettingsFromRow(row dbq.LoudnessSetting) LoudnessSettings {
|
||||||
|
return LoudnessSettings{
|
||||||
|
Enabled: row.Enabled,
|
||||||
|
BackfillConcurrency: row.BackfillConcurrency,
|
||||||
|
UpdatedAt: row.UpdatedAt.Time,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
package library
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// parseEbur128 runs the parser over a whole log, as computeLoudness does over
|
||||||
|
// ffmpeg's stderr.
|
||||||
|
func parseEbur128(log string) *ebur128Parser {
|
||||||
|
p := newEbur128Parser()
|
||||||
|
p.consume(strings.NewReader(log))
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// The fixture is real ffmpeg 6.1 output for 12 s of stereo tone whose first
|
||||||
|
// second is digital silence (testdata/ebur128_fixture.txt). Pinning the parser
|
||||||
|
// to captured output, not to a hand-written imitation of it, is the point: the
|
||||||
|
// per-block lines are where a format drift would hide.
|
||||||
|
func TestEbur128Parser_RealOutput(t *testing.T) {
|
||||||
|
raw, err := os.ReadFile("testdata/ebur128_fixture.txt")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read fixture: %v", err)
|
||||||
|
}
|
||||||
|
r := parseEbur128(string(raw)).result()
|
||||||
|
if r.err != nil {
|
||||||
|
t.Fatalf("result err = %v", r.err)
|
||||||
|
}
|
||||||
|
for name, c := range map[string]struct {
|
||||||
|
got *float32
|
||||||
|
want float32
|
||||||
|
}{
|
||||||
|
"integrated": {r.integratedLUFS, -10.7},
|
||||||
|
"true peak": {r.truePeakDBTP, -5.6},
|
||||||
|
"range": {r.rangeLU, 2.0},
|
||||||
|
} {
|
||||||
|
if c.got == nil || *c.got != c.want {
|
||||||
|
t.Errorf("%s = %v, want %v", name, c.got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 120 blocks, of which the 10 covering the silent second are below the
|
||||||
|
// absolute gate and not counted. The loudest is -8.1 LUFS, the quietest
|
||||||
|
// that passed -22.6.
|
||||||
|
var blocks int32
|
||||||
|
for _, c := range r.hist.counts {
|
||||||
|
blocks += c
|
||||||
|
}
|
||||||
|
if blocks != 110 {
|
||||||
|
t.Errorf("histogram holds %d blocks, want 110", blocks)
|
||||||
|
}
|
||||||
|
if r.hist.start != 474 || len(r.hist.counts) != 146 {
|
||||||
|
t.Errorf("histogram spans bins %d..%d, want 474..619 (-22.6..-8.1 LUFS)",
|
||||||
|
r.hist.start, int(r.hist.start)+len(r.hist.counts)-1)
|
||||||
|
}
|
||||||
|
if r.hist.counts[0] == 0 || r.hist.counts[len(r.hist.counts)-1] == 0 {
|
||||||
|
t.Errorf("histogram not trimmed to its occupied bins: %v", r.hist.counts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Album loudness is computed from these histograms (#4996), so the
|
||||||
|
// histogram has to reproduce ffmpeg's own figure.
|
||||||
|
got, ok := r.hist.gatedLoudness()
|
||||||
|
if !ok || math.Abs(got-(-10.7)) > 0.05 {
|
||||||
|
t.Errorf("loudness from the histogram = %.3f (ok=%v), want ffmpeg's -10.7 within 0.05", got, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEbur128Parser_SilenceIsAVerdictNotAMeasurement(t *testing.T) {
|
||||||
|
log := `[Parsed_ebur128_0 @ 0x1] t: 0.1 TARGET:-23 LUFS M:-120.7 S:-120.7 I: -70.0 LUFS LRA: 0.0 LU FTPK: -inf -inf dBFS TPK: -inf -inf dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x1] t: 0.2 TARGET:-23 LUFS M:-120.7 S:-120.7 I: -70.0 LUFS LRA: 0.0 LU FTPK: -inf -inf dBFS TPK: -inf -inf dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x1] Summary:
|
||||||
|
|
||||||
|
Integrated loudness:
|
||||||
|
I: -70.0 LUFS
|
||||||
|
Threshold: 0.0 LUFS
|
||||||
|
|
||||||
|
Loudness range:
|
||||||
|
LRA: 0.0 LU
|
||||||
|
Threshold: 0.0 LUFS
|
||||||
|
LRA low: 0.0 LUFS
|
||||||
|
LRA high: 0.0 LUFS
|
||||||
|
|
||||||
|
True peak:
|
||||||
|
Peak: -inf dBFS
|
||||||
|
`
|
||||||
|
r := parseEbur128(log).result()
|
||||||
|
if r.err != nil {
|
||||||
|
t.Fatalf("err = %v, want a clean result", r.err)
|
||||||
|
}
|
||||||
|
if r.integratedLUFS != nil || r.truePeakDBTP != nil || r.rangeLU != nil {
|
||||||
|
t.Errorf("silence measured as integrated=%v peak=%v range=%v, want all nil",
|
||||||
|
r.integratedLUFS, r.truePeakDBTP, r.rangeLU)
|
||||||
|
}
|
||||||
|
if !r.hist.empty() {
|
||||||
|
t.Errorf("silence left blocks in the histogram: %+v", r.hist)
|
||||||
|
}
|
||||||
|
if r.inconclusive() {
|
||||||
|
t.Errorf("silence reported as inconclusive; it is settled until the file changes")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEbur128Parser_NoSummaryIsUnreadable(t *testing.T) {
|
||||||
|
r := parseEbur128("[in#0 @ 0x1] Error opening input: Invalid data found when processing input\n").result()
|
||||||
|
if r.err == nil {
|
||||||
|
t.Fatal("a log with no summary produced a measurement")
|
||||||
|
}
|
||||||
|
if r.inconclusive() {
|
||||||
|
t.Errorf("err %v reads as inconclusive; ffmpeg ran and found nothing to measure", r.err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(r.err.Error(), "Invalid data found") {
|
||||||
|
t.Errorf("err %q does not carry ffmpeg's reason", r.err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEbur128Parser_LoudBlocksLandInTheTopBin(t *testing.T) {
|
||||||
|
p := parseEbur128(`[Parsed_ebur128_0 @ 0x1] t: 0.4 TARGET:-23 LUFS M: 12.4 S: 12.4 I: 12.4 LUFS
|
||||||
|
[Parsed_ebur128_0 @ 0x1] t: 0.5 TARGET:-23 LUFS M: -5.0 S: -5.0 I: -5.0 LUFS
|
||||||
|
`)
|
||||||
|
h := p.histogram()
|
||||||
|
if int(h.start)+len(h.counts) != loudnessHistBins || h.counts[len(h.counts)-1] != 1 {
|
||||||
|
t.Errorf("a +12.4 LUFS block was not counted in the top bin: %+v", h)
|
||||||
|
}
|
||||||
|
if h.start != 650 || h.counts[0] != 1 {
|
||||||
|
t.Errorf("a -5.0 LUFS block is not in bin 650: start %d, first %d", h.start, h.counts[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBlockHistogram_GatedLoudness(t *testing.T) {
|
||||||
|
bin := func(lufs float64) int { return int(math.Round((lufs - loudnessHistFloor) * loudnessHistPerLU)) }
|
||||||
|
hist := func(blocks map[float64]int32) blockHistogram {
|
||||||
|
lo, hi := loudnessHistBins, 0
|
||||||
|
for l := range blocks {
|
||||||
|
lo, hi = min(lo, bin(l)), max(hi, bin(l))
|
||||||
|
}
|
||||||
|
h := blockHistogram{start: int16(lo), counts: make([]int32, hi-lo+1)}
|
||||||
|
for l, n := range blocks {
|
||||||
|
h.counts[bin(l)-lo] = n
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
blocks map[float64]int32
|
||||||
|
want float64
|
||||||
|
}{
|
||||||
|
// One level throughout is that level.
|
||||||
|
{"steady", map[float64]int32{-14: 50}, -14},
|
||||||
|
// The quiet half is 30 LU below the loud half, past the relative gate
|
||||||
|
// (10 LU below the ungated loudness, here about -13), so it is dropped
|
||||||
|
// and the result is the loud half alone.
|
||||||
|
{"quiet passage gated out", map[float64]int32{-10: 100, -40: 100}, -10},
|
||||||
|
// 6 LU apart is inside the gate, so both count, energy-weighted: the
|
||||||
|
// result sits nearer the louder level than the midpoint (-15) does.
|
||||||
|
{"both inside the gate", map[float64]int32{-12: 100, -18: 100}, -14.037},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got, ok := hist(c.blocks).gatedLoudness()
|
||||||
|
if !ok || math.Abs(got-c.want) > 0.01 {
|
||||||
|
t.Errorf("%s: gatedLoudness = %.3f (ok=%v), want %.3f", c.name, got, ok, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, ok := (blockHistogram{}).gatedLoudness(); ok {
|
||||||
|
t.Errorf("an empty histogram reported a loudness")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEbur128Args(t *testing.T) {
|
||||||
|
args := ebur128Args("/music/a.flac")
|
||||||
|
joined := strings.Join(args, " ")
|
||||||
|
for _, want := range []string{"peak=true", "dualmono=true", "framelog=info", "-map 0:a:0", "-nostdin", "-f null -"} {
|
||||||
|
if !strings.Contains(joined, want) {
|
||||||
|
t.Errorf("args %q lack %q", joined, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The path is its own argument, never spliced into the filter string.
|
||||||
|
if i := slices.Index(args, "-i"); i < 0 || args[i+1] != "/music/a.flac" {
|
||||||
|
t.Errorf("args %q do not pass the path after -i", args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoudnessTimeout_ScalesWithLength(t *testing.T) {
|
||||||
|
if got := loudnessTimeout(0); got != loudnessBaseTimeout {
|
||||||
|
t.Errorf("unknown length: %s, want the base %s", got, loudnessBaseTimeout)
|
||||||
|
}
|
||||||
|
if got, want := loudnessTimeout(int32((2 * time.Hour).Milliseconds())), loudnessBaseTimeout+30*time.Minute; got != want {
|
||||||
|
t.Errorf("two-hour mix: %s, want %s", got, want)
|
||||||
|
}
|
||||||
|
if got := loudnessTimeout(-5); got != loudnessBaseTimeout {
|
||||||
|
t.Errorf("negative length: %s, want the base %s", got, loudnessBaseTimeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoudnessResult_Inconclusive(t *testing.T) {
|
||||||
|
for _, err := range []error{
|
||||||
|
fmt.Errorf("ffmpeg: %w", errLoudnessTimeout),
|
||||||
|
fmt.Errorf("ffmpeg: %w", context.Canceled),
|
||||||
|
fmt.Errorf("ffmpeg: %w", exec.ErrNotFound),
|
||||||
|
} {
|
||||||
|
if !(loudnessResult{err: err}).inconclusive() {
|
||||||
|
t.Errorf("%v: not inconclusive, so it would be stored as a verdict", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (loudnessResult{err: errors.New("ffmpeg exited 1: moov atom not found")}).inconclusive() {
|
||||||
|
t.Errorf("a decode failure read as inconclusive; it would be retried every pass")
|
||||||
|
}
|
||||||
|
if (loudnessResult{}).inconclusive() {
|
||||||
|
t.Errorf("a clean result read as inconclusive")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackfillLoudnessResult_Add(t *testing.T) {
|
||||||
|
var r BackfillLoudnessResult
|
||||||
|
for _, o := range []loudnessOutcome{
|
||||||
|
loudnessMeasured, loudnessMeasured, loudnessSilent, loudnessUnreadable,
|
||||||
|
loudnessInconclusive, loudnessStoreFailed,
|
||||||
|
} {
|
||||||
|
r.add(o)
|
||||||
|
}
|
||||||
|
// A failed write stored nothing, so it is retried like an inconclusive one.
|
||||||
|
want := BackfillLoudnessResult{Processed: 6, Measured: 2, Silent: 1, Unreadable: 1, Inconclusive: 2}
|
||||||
|
if r != want {
|
||||||
|
t.Errorf("tally = %+v, want %+v", r, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var nilSvc *LoudnessSettingsService
|
||||||
|
if got := nilSvc.Get(); got != DefaultLoudnessSettings {
|
||||||
|
t.Errorf("nil service Get = %+v, want the defaults", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -402,6 +402,13 @@ func (s *Scanner) scanFile(
|
|||||||
// must not outlive them, or the sweep would compare audio that is gone.
|
// must not outlive them, or the sweep would compare audio that is gone.
|
||||||
s.logger.Warn("fingerprint: clearing stale fingerprint failed", "path", path, "err", err)
|
s.logger.Warn("fingerprint: clearing stale fingerprint failed", "path", path, "err", err)
|
||||||
}
|
}
|
||||||
|
// Loudness is measured by its own worker, never inline: it decodes the
|
||||||
|
// whole file (see loudness_backfill.go). The scan's part is to drop a
|
||||||
|
// measurement of bytes that are gone, so clients stop leveling this
|
||||||
|
// track by the old file's loudness and the worker measures it again.
|
||||||
|
if err := q.DeleteTrackLoudness(ctx, track.ID); err != nil {
|
||||||
|
s.logger.Warn("loudness: clearing stale measurement failed", "path", path, "err", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if knownTrack {
|
if knownTrack {
|
||||||
|
|||||||
+149
@@ -0,0 +1,149 @@
|
|||||||
|
Input #0, flac, from 'fixture.flac':
|
||||||
|
Metadata:
|
||||||
|
encoder : Lavf60.16.100
|
||||||
|
Duration: 00:00:12.00, start: 0.000000, bitrate: 789 kb/s
|
||||||
|
Stream #0:0: Audio: flac, 44100 Hz, stereo, s32 (24 bit)
|
||||||
|
Stream mapping:
|
||||||
|
Stream #0:0 -> #0:0 (flac (native) -> pcm_s16le (native))
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 0.0999773 TARGET:-23 LUFS M:-120.7 S:-120.7 I: -70.0 LUFS LRA: 0.0 LU FTPK: -inf -inf dBFS TPK: -inf -inf dBFS
|
||||||
|
Output #0, null, to 'pipe:':
|
||||||
|
Metadata:
|
||||||
|
encoder : Lavf60.16.100
|
||||||
|
Stream #0:0: Audio: pcm_s16le, 44100 Hz, stereo, s16, 1411 kb/s
|
||||||
|
Metadata:
|
||||||
|
encoder : Lavc60.31.102 pcm_s16le
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 0.199977 TARGET:-23 LUFS M:-120.7 S:-120.7 I: -70.0 LUFS LRA: 0.0 LU FTPK: -inf -inf dBFS TPK: -inf -inf dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 0.299977 TARGET:-23 LUFS M:-120.7 S:-120.7 I: -70.0 LUFS LRA: 0.0 LU FTPK: -inf -inf dBFS TPK: -inf -inf dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 0.399977 TARGET:-23 LUFS M:-163.2 S:-120.7 I: -70.0 LUFS LRA: 0.0 LU FTPK: -inf -inf dBFS TPK: -inf -inf dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 0.499977 TARGET:-23 LUFS M:-163.2 S:-120.7 I: -70.0 LUFS LRA: 0.0 LU FTPK: -inf -inf dBFS TPK: -inf -inf dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 0.599977 TARGET:-23 LUFS M:-163.2 S:-120.7 I: -70.0 LUFS LRA: 0.0 LU FTPK: -inf -inf dBFS TPK: -inf -inf dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 0.699977 TARGET:-23 LUFS M:-163.2 S:-120.7 I: -70.0 LUFS LRA: 0.0 LU FTPK: -inf -inf dBFS TPK: -inf -inf dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 0.799977 TARGET:-23 LUFS M:-163.2 S:-120.7 I: -70.0 LUFS LRA: 0.0 LU FTPK: -inf -inf dBFS TPK: -inf -inf dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 0.899977 TARGET:-23 LUFS M:-163.2 S:-120.7 I: -70.0 LUFS LRA: 0.0 LU FTPK: -inf -inf dBFS TPK: -inf -inf dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 0.999977 TARGET:-23 LUFS M:-163.2 S:-120.7 I: -70.0 LUFS LRA: 0.0 LU FTPK: -inf -inf dBFS TPK: -inf -inf dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 1.09998 TARGET:-23 LUFS M: -15.4 S:-120.7 I: -15.4 LUFS LRA: 0.0 LU FTPK: -7.1 -10.7 dBFS TPK: -7.1 -10.7 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 1.19998 TARGET:-23 LUFS M: -12.2 S:-120.7 I: -13.5 LUFS LRA: 0.0 LU FTPK: -6.6 -10.9 dBFS TPK: -6.6 -10.7 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 1.29998 TARGET:-23 LUFS M: -10.3 S:-120.7 I: -12.2 LUFS LRA: 0.0 LU FTPK: -6.3 -11.1 dBFS TPK: -6.3 -10.7 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 1.39998 TARGET:-23 LUFS M: -9.0 S:-120.7 I: -11.1 LUFS LRA: 0.0 LU FTPK: -6.0 -11.4 dBFS TPK: -6.0 -10.7 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 1.49998 TARGET:-23 LUFS M: -8.8 S:-120.7 I: -10.6 LUFS LRA: 0.0 LU FTPK: -5.8 -11.7 dBFS TPK: -5.8 -10.7 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 1.59998 TARGET:-23 LUFS M: -8.7 S:-120.7 I: -10.2 LUFS LRA: 0.0 LU FTPK: -5.7 -12.1 dBFS TPK: -5.7 -10.7 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 1.69998 TARGET:-23 LUFS M: -8.6 S:-120.7 I: -9.9 LUFS LRA: 0.0 LU FTPK: -5.6 -12.4 dBFS TPK: -5.6 -10.7 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 1.79998 TARGET:-23 LUFS M: -8.5 S:-120.7 I: -9.7 LUFS LRA: 0.0 LU FTPK: -5.6 -12.8 dBFS TPK: -5.6 -10.7 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 1.89998 TARGET:-23 LUFS M: -8.6 S:-120.7 I: -9.6 LUFS LRA: 0.0 LU FTPK: -5.6 -13.3 dBFS TPK: -5.6 -10.7 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 1.99998 TARGET:-23 LUFS M: -8.6 S:-120.7 I: -9.5 LUFS LRA: 0.0 LU FTPK: -5.7 -13.8 dBFS TPK: -5.6 -10.7 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 2.09998 TARGET:-23 LUFS M: -8.8 S:-120.7 I: -9.4 LUFS LRA: 0.0 LU FTPK: -5.8 -14.3 dBFS TPK: -5.6 -10.7 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 2.19998 TARGET:-23 LUFS M: -8.9 S:-120.7 I: -9.4 LUFS LRA: 0.0 LU FTPK: -6.0 -14.9 dBFS TPK: -5.6 -10.7 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 2.29998 TARGET:-23 LUFS M: -9.2 S:-120.7 I: -9.3 LUFS LRA: 0.0 LU FTPK: -6.3 -15.6 dBFS TPK: -5.6 -10.7 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 2.39998 TARGET:-23 LUFS M: -9.5 S:-120.7 I: -9.4 LUFS LRA: 0.0 LU FTPK: -6.7 -16.3 dBFS TPK: -5.6 -10.7 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 2.49998 TARGET:-23 LUFS M: -9.9 S:-120.7 I: -9.4 LUFS LRA: 0.0 LU FTPK: -7.1 -17.2 dBFS TPK: -5.6 -10.7 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 2.59998 TARGET:-23 LUFS M: -10.4 S:-120.7 I: -9.5 LUFS LRA: 0.0 LU FTPK: -7.7 -18.1 dBFS TPK: -5.6 -10.7 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 2.69998 TARGET:-23 LUFS M: -11.0 S:-120.7 I: -9.5 LUFS LRA: 0.0 LU FTPK: -8.3 -19.2 dBFS TPK: -5.6 -10.7 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 2.79998 TARGET:-23 LUFS M: -11.6 S:-120.7 I: -9.6 LUFS LRA: 0.0 LU FTPK: -9.1 -20.4 dBFS TPK: -5.6 -10.7 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 2.89998 TARGET:-23 LUFS M: -12.4 S:-120.7 I: -9.7 LUFS LRA: 0.0 LU FTPK: -10.0 -21.8 dBFS TPK: -5.6 -10.7 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 2.99998 TARGET:-23 LUFS M: -13.4 S: -11.5 I: -9.9 LUFS LRA: 20.0 LU FTPK: -11.1 -23.6 dBFS TPK: -5.6 -10.7 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 3.09998 TARGET:-23 LUFS M: -14.5 S: -11.5 I: -10.0 LUFS LRA: 20.0 LU FTPK: -12.5 -25.8 dBFS TPK: -5.6 -10.7 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 3.19998 TARGET:-23 LUFS M: -15.8 S: -11.4 I: -10.1 LUFS LRA: 20.0 LU FTPK: -14.1 -28.2 dBFS TPK: -5.6 -10.7 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 3.29998 TARGET:-23 LUFS M: -17.3 S: -11.4 I: -10.3 LUFS LRA: 20.0 LU FTPK: -16.2 -25.4 dBFS TPK: -5.6 -10.7 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 3.39998 TARGET:-23 LUFS M: -19.1 S: -11.4 I: -10.5 LUFS LRA: 0.1 LU FTPK: -19.2 -23.3 dBFS TPK: -5.6 -10.7 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 3.49998 TARGET:-23 LUFS M: -21.1 S: -11.4 I: -10.5 LUFS LRA: 0.1 LU FTPK: -23.7 -21.6 dBFS TPK: -5.6 -10.7 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 3.59998 TARGET:-23 LUFS M: -22.6 S: -11.4 I: -10.5 LUFS LRA: 0.1 LU FTPK: -22.6 -20.2 dBFS TPK: -5.6 -10.7 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 3.69998 TARGET:-23 LUFS M: -22.5 S: -11.4 I: -10.5 LUFS LRA: 0.1 LU FTPK: -18.5 -19.0 dBFS TPK: -5.6 -10.7 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 3.79998 TARGET:-23 LUFS M: -20.9 S: -11.3 I: -10.6 LUFS LRA: 0.2 LU FTPK: -15.8 -17.9 dBFS TPK: -5.6 -10.7 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 3.89998 TARGET:-23 LUFS M: -18.9 S: -11.3 I: -10.9 LUFS LRA: 0.2 LU FTPK: -13.8 -17.0 dBFS TPK: -5.6 -10.7 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 3.99998 TARGET:-23 LUFS M: -17.1 S: -11.2 I: -11.0 LUFS LRA: 0.2 LU FTPK: -12.2 -16.2 dBFS TPK: -5.6 -10.7 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 4.09998 TARGET:-23 LUFS M: -15.6 S: -11.4 I: -11.1 LUFS LRA: 0.2 LU FTPK: -10.9 -15.5 dBFS TPK: -5.6 -10.7 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 4.19998 TARGET:-23 LUFS M: -14.3 S: -11.5 I: -11.2 LUFS LRA: 0.2 LU FTPK: -9.8 -14.8 dBFS TPK: -5.6 -10.7 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 4.29998 TARGET:-23 LUFS M: -13.2 S: -11.7 I: -11.3 LUFS LRA: 0.4 LU FTPK: -8.9 -14.2 dBFS TPK: -5.6 -10.7 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 4.39998 TARGET:-23 LUFS M: -12.3 S: -11.8 I: -11.3 LUFS LRA: 0.4 LU FTPK: -8.2 -13.7 dBFS TPK: -5.6 -10.7 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 4.49998 TARGET:-23 LUFS M: -11.5 S: -11.9 I: -11.3 LUFS LRA: 0.5 LU FTPK: -7.6 -13.2 dBFS TPK: -5.6 -10.7 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 4.59998 TARGET:-23 LUFS M: -10.8 S: -12.0 I: -11.3 LUFS LRA: 0.6 LU FTPK: -7.0 -12.8 dBFS TPK: -5.6 -10.7 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 4.69998 TARGET:-23 LUFS M: -10.2 S: -12.0 I: -11.2 LUFS LRA: 0.7 LU FTPK: -6.6 -12.4 dBFS TPK: -5.6 -10.7 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 4.79998 TARGET:-23 LUFS M: -9.7 S: -12.1 I: -11.2 LUFS LRA: 0.7 LU FTPK: -6.3 -12.0 dBFS TPK: -5.6 -10.7 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 4.89998 TARGET:-23 LUFS M: -9.3 S: -12.1 I: -11.1 LUFS LRA: 0.8 LU FTPK: -6.0 -11.7 dBFS TPK: -5.6 -10.7 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 4.99998 TARGET:-23 LUFS M: -8.9 S: -12.1 I: -11.0 LUFS LRA: 0.8 LU FTPK: -5.8 -11.4 dBFS TPK: -5.6 -10.7 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 5.09998 TARGET:-23 LUFS M: -8.6 S: -12.0 I: -11.0 LUFS LRA: 0.8 LU FTPK: -5.7 -11.1 dBFS TPK: -5.6 -10.7 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 5.19998 TARGET:-23 LUFS M: -8.4 S: -11.9 I: -10.8 LUFS LRA: 0.8 LU FTPK: -5.6 -10.8 dBFS TPK: -5.6 -10.7 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 5.29998 TARGET:-23 LUFS M: -8.3 S: -11.8 I: -10.7 LUFS LRA: 0.8 LU FTPK: -5.6 -10.6 dBFS TPK: -5.6 -10.6 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 5.39998 TARGET:-23 LUFS M: -8.2 S: -11.7 I: -10.5 LUFS LRA: 0.8 LU FTPK: -5.6 -10.4 dBFS TPK: -5.6 -10.4 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 5.49998 TARGET:-23 LUFS M: -8.1 S: -11.5 I: -10.4 LUFS LRA: 0.8 LU FTPK: -5.7 -10.2 dBFS TPK: -5.6 -10.2 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 5.59998 TARGET:-23 LUFS M: -8.1 S: -11.4 I: -10.4 LUFS LRA: 0.8 LU FTPK: -5.8 -10.1 dBFS TPK: -5.6 -10.1 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 5.69998 TARGET:-23 LUFS M: -8.2 S: -11.2 I: -10.3 LUFS LRA: 0.8 LU FTPK: -6.0 -10.0 dBFS TPK: -5.6 -10.0 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 5.79998 TARGET:-23 LUFS M: -8.3 S: -11.1 I: -10.2 LUFS LRA: 0.8 LU FTPK: -6.3 -9.9 dBFS TPK: -5.6 -9.9 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 5.89998 TARGET:-23 LUFS M: -8.4 S: -10.9 I: -10.2 LUFS LRA: 1.0 LU FTPK: -6.7 -9.8 dBFS TPK: -5.6 -9.8 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 5.99998 TARGET:-23 LUFS M: -8.7 S: -10.7 I: -10.2 LUFS LRA: 1.0 LU FTPK: -7.2 -9.7 dBFS TPK: -5.6 -9.7 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 6.09998 TARGET:-23 LUFS M: -8.9 S: -10.6 I: -10.1 LUFS LRA: 1.2 LU FTPK: -7.7 -9.7 dBFS TPK: -5.6 -9.7 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 6.19998 TARGET:-23 LUFS M: -9.2 S: -10.4 I: -10.1 LUFS LRA: 1.3 LU FTPK: -8.4 -9.6 dBFS TPK: -5.6 -9.6 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 6.29998 TARGET:-23 LUFS M: -9.6 S: -10.3 I: -10.1 LUFS LRA: 1.5 LU FTPK: -9.2 -9.6 dBFS TPK: -5.6 -9.6 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 6.39998 TARGET:-23 LUFS M: -10.0 S: -10.2 I: -10.1 LUFS LRA: 1.6 LU FTPK: -10.1 -9.6 dBFS TPK: -5.6 -9.6 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 6.49998 TARGET:-23 LUFS M: -10.4 S: -10.0 I: -10.1 LUFS LRA: 1.8 LU FTPK: -11.2 -9.6 dBFS TPK: -5.6 -9.6 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 6.59998 TARGET:-23 LUFS M: -10.9 S: -10.0 I: -10.1 LUFS LRA: 1.9 LU FTPK: -12.6 -9.7 dBFS TPK: -5.6 -9.6 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 6.69998 TARGET:-23 LUFS M: -11.4 S: -9.9 I: -10.1 LUFS LRA: 2.0 LU FTPK: -14.3 -9.7 dBFS TPK: -5.6 -9.6 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 6.79998 TARGET:-23 LUFS M: -11.9 S: -9.8 I: -10.2 LUFS LRA: 2.1 LU FTPK: -16.5 -9.8 dBFS TPK: -5.6 -9.6 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 6.89998 TARGET:-23 LUFS M: -12.5 S: -9.8 I: -10.2 LUFS LRA: 2.1 LU FTPK: -19.5 -9.9 dBFS TPK: -5.6 -9.6 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 6.99998 TARGET:-23 LUFS M: -12.9 S: -9.8 I: -10.2 LUFS LRA: 2.2 LU FTPK: -24.3 -10.0 dBFS TPK: -5.6 -9.6 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 7.09998 TARGET:-23 LUFS M: -13.3 S: -9.8 I: -10.3 LUFS LRA: 2.2 LU FTPK: -22.2 -10.1 dBFS TPK: -5.6 -9.6 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 7.19998 TARGET:-23 LUFS M: -13.5 S: -9.8 I: -10.3 LUFS LRA: 2.2 LU FTPK: -18.2 -10.3 dBFS TPK: -5.6 -9.6 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 7.29998 TARGET:-23 LUFS M: -13.5 S: -9.8 I: -10.3 LUFS LRA: 2.2 LU FTPK: -15.6 -10.5 dBFS TPK: -5.6 -9.6 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 7.39998 TARGET:-23 LUFS M: -13.3 S: -9.9 I: -10.4 LUFS LRA: 2.2 LU FTPK: -13.6 -10.7 dBFS TPK: -5.6 -9.6 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 7.49998 TARGET:-23 LUFS M: -13.0 S: -9.9 I: -10.4 LUFS LRA: 2.2 LU FTPK: -12.0 -10.9 dBFS TPK: -5.6 -9.6 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 7.59998 TARGET:-23 LUFS M: -12.6 S: -10.0 I: -10.4 LUFS LRA: 2.2 LU FTPK: -10.8 -11.2 dBFS TPK: -5.6 -9.6 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 7.69998 TARGET:-23 LUFS M: -12.1 S: -10.0 I: -10.5 LUFS LRA: 2.2 LU FTPK: -9.7 -11.5 dBFS TPK: -5.6 -9.6 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 7.79998 TARGET:-23 LUFS M: -11.7 S: -10.1 I: -10.5 LUFS LRA: 2.2 LU FTPK: -8.9 -11.8 dBFS TPK: -5.6 -9.6 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 7.89998 TARGET:-23 LUFS M: -11.2 S: -10.1 I: -10.5 LUFS LRA: 2.2 LU FTPK: -8.1 -12.1 dBFS TPK: -5.6 -9.6 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 7.99998 TARGET:-23 LUFS M: -10.8 S: -10.2 I: -10.5 LUFS LRA: 2.2 LU FTPK: -7.5 -12.5 dBFS TPK: -5.6 -9.6 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 8.09998 TARGET:-23 LUFS M: -10.4 S: -10.3 I: -10.5 LUFS LRA: 2.2 LU FTPK: -7.0 -12.9 dBFS TPK: -5.6 -9.6 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 8.19998 TARGET:-23 LUFS M: -10.1 S: -10.4 I: -10.5 LUFS LRA: 2.2 LU FTPK: -6.6 -13.4 dBFS TPK: -5.6 -9.6 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 8.29998 TARGET:-23 LUFS M: -9.8 S: -10.4 I: -10.5 LUFS LRA: 2.2 LU FTPK: -6.2 -13.9 dBFS TPK: -5.6 -9.6 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 8.39998 TARGET:-23 LUFS M: -9.5 S: -10.5 I: -10.5 LUFS LRA: 2.2 LU FTPK: -6.0 -14.4 dBFS TPK: -5.6 -9.6 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 8.49998 TARGET:-23 LUFS M: -9.3 S: -10.5 I: -10.4 LUFS LRA: 2.2 LU FTPK: -5.8 -15.0 dBFS TPK: -5.6 -9.6 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 8.59998 TARGET:-23 LUFS M: -9.2 S: -10.6 I: -10.4 LUFS LRA: 2.2 LU FTPK: -5.7 -15.7 dBFS TPK: -5.6 -9.6 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 8.69998 TARGET:-23 LUFS M: -9.1 S: -10.6 I: -10.4 LUFS LRA: 2.2 LU FTPK: -5.6 -16.5 dBFS TPK: -5.6 -9.6 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 8.79998 TARGET:-23 LUFS M: -9.0 S: -10.6 I: -10.4 LUFS LRA: 2.2 LU FTPK: -5.6 -17.3 dBFS TPK: -5.6 -9.6 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 8.89998 TARGET:-23 LUFS M: -9.0 S: -10.6 I: -10.4 LUFS LRA: 2.2 LU FTPK: -5.6 -18.3 dBFS TPK: -5.6 -9.6 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 8.99998 TARGET:-23 LUFS M: -9.1 S: -10.7 I: -10.3 LUFS LRA: 2.2 LU FTPK: -5.7 -19.4 dBFS TPK: -5.6 -9.6 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 9.09998 TARGET:-23 LUFS M: -9.2 S: -10.7 I: -10.3 LUFS LRA: 2.2 LU FTPK: -5.9 -20.6 dBFS TPK: -5.6 -9.6 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 9.19998 TARGET:-23 LUFS M: -9.4 S: -10.7 I: -10.3 LUFS LRA: 2.2 LU FTPK: -6.1 -22.1 dBFS TPK: -5.6 -9.6 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 9.29998 TARGET:-23 LUFS M: -9.7 S: -10.7 I: -10.3 LUFS LRA: 2.2 LU FTPK: -6.4 -23.9 dBFS TPK: -5.6 -9.6 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 9.39998 TARGET:-23 LUFS M: -10.0 S: -10.7 I: -10.3 LUFS LRA: 2.1 LU FTPK: -6.7 -26.3 dBFS TPK: -5.6 -9.6 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 9.49998 TARGET:-23 LUFS M: -10.4 S: -10.7 I: -10.3 LUFS LRA: 2.1 LU FTPK: -7.2 -27.7 dBFS TPK: -5.6 -9.6 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 9.59998 TARGET:-23 LUFS M: -10.9 S: -10.7 I: -10.3 LUFS LRA: 2.1 LU FTPK: -7.8 -25.0 dBFS TPK: -5.6 -9.6 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 9.69998 TARGET:-23 LUFS M: -11.4 S: -10.7 I: -10.3 LUFS LRA: 2.1 LU FTPK: -8.4 -23.0 dBFS TPK: -5.6 -9.6 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 9.79998 TARGET:-23 LUFS M: -12.0 S: -10.7 I: -10.3 LUFS LRA: 2.1 LU FTPK: -9.2 -21.3 dBFS TPK: -5.6 -9.6 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 9.89998 TARGET:-23 LUFS M: -12.7 S: -10.7 I: -10.4 LUFS LRA: 2.1 LU FTPK: -10.2 -20.0 dBFS TPK: -5.6 -9.6 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 9.99998 TARGET:-23 LUFS M: -13.5 S: -10.7 I: -10.4 LUFS LRA: 2.1 LU FTPK: -11.3 -18.8 dBFS TPK: -5.6 -9.6 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 10.1 TARGET:-23 LUFS M: -14.4 S: -10.7 I: -10.4 LUFS LRA: 2.1 LU FTPK: -12.7 -17.8 dBFS TPK: -5.6 -9.6 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 10.2 TARGET:-23 LUFS M: -15.3 S: -10.8 I: -10.4 LUFS LRA: 2.1 LU FTPK: -14.5 -16.9 dBFS TPK: -5.6 -9.6 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 10.3 TARGET:-23 LUFS M: -16.2 S: -10.8 I: -10.5 LUFS LRA: 2.1 LU FTPK: -16.7 -16.1 dBFS TPK: -5.6 -9.6 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 10.4 TARGET:-23 LUFS M: -17.1 S: -10.9 I: -10.5 LUFS LRA: 2.1 LU FTPK: -19.8 -15.4 dBFS TPK: -5.6 -9.6 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 10.5 TARGET:-23 LUFS M: -17.8 S: -11.0 I: -10.6 LUFS LRA: 2.1 LU FTPK: -24.9 -14.7 dBFS TPK: -5.6 -9.6 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 10.6 TARGET:-23 LUFS M: -18.0 S: -11.1 I: -10.6 LUFS LRA: 2.1 LU FTPK: -21.7 -14.1 dBFS TPK: -5.6 -9.6 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 10.7 TARGET:-23 LUFS M: -17.6 S: -11.1 I: -10.6 LUFS LRA: 2.1 LU FTPK: -17.9 -13.6 dBFS TPK: -5.6 -9.6 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 10.8 TARGET:-23 LUFS M: -16.7 S: -11.2 I: -10.7 LUFS LRA: 2.1 LU FTPK: -15.4 -13.1 dBFS TPK: -5.6 -9.6 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 10.9 TARGET:-23 LUFS M: -15.7 S: -11.3 I: -10.7 LUFS LRA: 2.1 LU FTPK: -13.4 -12.7 dBFS TPK: -5.6 -9.6 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 11 TARGET:-23 LUFS M: -14.6 S: -11.4 I: -10.7 LUFS LRA: 2.1 LU FTPK: -11.9 -12.3 dBFS TPK: -5.6 -9.6 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 11.1 TARGET:-23 LUFS M: -13.6 S: -11.5 I: -10.8 LUFS LRA: 2.1 LU FTPK: -10.7 -11.9 dBFS TPK: -5.6 -9.6 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 11.2 TARGET:-23 LUFS M: -12.7 S: -11.6 I: -10.8 LUFS LRA: 2.1 LU FTPK: -9.6 -11.6 dBFS TPK: -5.6 -9.6 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 11.3 TARGET:-23 LUFS M: -11.8 S: -11.7 I: -10.8 LUFS LRA: 2.1 LU FTPK: -8.8 -11.3 dBFS TPK: -5.6 -9.6 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 11.4 TARGET:-23 LUFS M: -11.1 S: -11.7 I: -10.8 LUFS LRA: 2.0 LU FTPK: -8.1 -11.0 dBFS TPK: -5.6 -9.6 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 11.5 TARGET:-23 LUFS M: -10.5 S: -11.7 I: -10.8 LUFS LRA: 2.0 LU FTPK: -7.4 -10.8 dBFS TPK: -5.6 -9.6 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 11.6 TARGET:-23 LUFS M: -9.9 S: -11.8 I: -10.8 LUFS LRA: 2.0 LU FTPK: -6.9 -10.6 dBFS TPK: -5.6 -9.6 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 11.7 TARGET:-23 LUFS M: -9.5 S: -11.8 I: -10.8 LUFS LRA: 2.0 LU FTPK: -6.5 -10.4 dBFS TPK: -5.6 -9.6 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 11.8 TARGET:-23 LUFS M: -9.0 S: -11.7 I: -10.7 LUFS LRA: 2.0 LU FTPK: -6.2 -10.2 dBFS TPK: -5.6 -9.6 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 11.9 TARGET:-23 LUFS M: -8.7 S: -11.7 I: -10.7 LUFS LRA: 2.0 LU FTPK: -5.9 -10.1 dBFS TPK: -5.6 -9.6 dBFS
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 12 TARGET:-23 LUFS M: -8.4 S: -11.6 I: -10.7 LUFS LRA: 2.0 LU FTPK: -5.8 -10.0 dBFS TPK: -5.6 -9.6 dBFS
|
||||||
|
[out#0/null @ 0x5ebc43345c00] video:0kB audio:2067kB subtitle:0kB other streams:0kB global headers:0kB muxing overhead: unknown
|
||||||
|
size=N/A time=00:00:11.90 bitrate=N/A speed= 161x
|
||||||
|
[Parsed_ebur128_0 @ 0x5ebc43357b00] Summary:
|
||||||
|
|
||||||
|
Integrated loudness:
|
||||||
|
I: -10.7 LUFS
|
||||||
|
Threshold: -20.8 LUFS
|
||||||
|
|
||||||
|
Loudness range:
|
||||||
|
LRA: 2.0 LU
|
||||||
|
Threshold: -30.9 LUFS
|
||||||
|
LRA low: -11.8 LUFS
|
||||||
|
LRA high: -9.8 LUFS
|
||||||
|
|
||||||
|
True peak:
|
||||||
|
Peak: -5.6 dBFS
|
||||||
@@ -105,6 +105,9 @@ type Server struct {
|
|||||||
// fingerprint workers, so a save from the admin card reaches them without a
|
// fingerprint workers, so a save from the admin card reaches them without a
|
||||||
// restart. Router() constructs a fallback when nil (tests).
|
// restart. Router() constructs a fallback when nil (tests).
|
||||||
FingerprintSettings *library.FingerprintSettingsService
|
FingerprintSettings *library.FingerprintSettingsService
|
||||||
|
// LoudnessSettings is the DB-backed loudness analysis policy (M464 #4995),
|
||||||
|
// shared with the loudness backfill. Nil makes the router load its own.
|
||||||
|
LoudnessSettings *library.LoudnessSettingsService
|
||||||
// ReacqSettings is the DB-backed missing-file re-acquisition policy
|
// ReacqSettings is the DB-backed missing-file re-acquisition policy
|
||||||
// (milestone #290) — the same instance the sweeper in cmd/minstrel/main.go
|
// (milestone #290) — the same instance the sweeper in cmd/minstrel/main.go
|
||||||
// reads, so a save from the admin card reaches it without a restart
|
// reads, so a save from the admin card reaches it without a restart
|
||||||
@@ -214,7 +217,15 @@ func (s *Server) Router() http.Handler {
|
|||||||
s.Logger.Warn("fingerprint settings unavailable; serving defaults", "err", err)
|
s.Logger.Warn("fingerprint settings unavailable; serving defaults", "err", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, recSettings, lidarrCfg, lidarrReqs, lidarrQuar, tracksSvc, playlistsSvc, s.CoverEnricher, s.CoverSettings, s.TagSettings, s.LibraryScanner, s.ScanCfg, s.DataDir, smtpSender, bus, s.PlaylistScheduler, s.StreamSecret, netSettings, reacqSettings, fpSettings)
|
loudSettings := s.LoudnessSettings
|
||||||
|
if loudSettings == nil {
|
||||||
|
var err error
|
||||||
|
loudSettings, err = library.NewLoudnessSettingsService(context.Background(), s.Pool)
|
||||||
|
if err != nil {
|
||||||
|
s.Logger.Warn("loudness settings unavailable; serving defaults", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, recSettings, lidarrCfg, lidarrReqs, lidarrQuar, tracksSvc, playlistsSvc, s.CoverEnricher, s.CoverSettings, s.TagSettings, s.LibraryScanner, s.ScanCfg, s.DataDir, smtpSender, bus, s.PlaylistScheduler, s.StreamSecret, netSettings, reacqSettings, fpSettings, loudSettings)
|
||||||
// /api/admin/scan is the only admin route owned by the server package
|
// /api/admin/scan is the only admin route owned by the server package
|
||||||
// (it needs the Scanner). Register it as a single inline-middleware
|
// (it needs the Scanner). Register it as a single inline-middleware
|
||||||
// route — using r.Route("/api/admin", ...) here would create a second
|
// route — using r.Route("/api/admin", ...) here would create a second
|
||||||
|
|||||||
@@ -364,6 +364,38 @@ export async function updateFingerprintSettings(
|
|||||||
return api.put<FingerprintSettings>('/api/admin/library/fingerprint-settings', s);
|
return api.put<FingerprintSettings>('/api/admin/library/fingerprint-settings', s);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Loudness analysis (#4995) -------------------------------------------------
|
||||||
|
|
||||||
|
export type LoudnessCoverage = {
|
||||||
|
total: number;
|
||||||
|
measured: number;
|
||||||
|
// Read fine, but no part of the track was loud enough to measure: silence,
|
||||||
|
// or a file shorter than half a second.
|
||||||
|
silent: number;
|
||||||
|
unreadable: number;
|
||||||
|
pending: number;
|
||||||
|
// False when the operator has switched analysis off: pending then never
|
||||||
|
// shrinks, and nothing should read as progress.
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getLoudnessCoverage(): Promise<LoudnessCoverage> {
|
||||||
|
return api.get<LoudnessCoverage>('/api/admin/library/loudness');
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LoudnessSettings = {
|
||||||
|
enabled: boolean;
|
||||||
|
backfill_concurrency: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getLoudnessSettings(): Promise<LoudnessSettings> {
|
||||||
|
return api.get<LoudnessSettings>('/api/admin/library/loudness-settings');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateLoudnessSettings(s: LoudnessSettings): Promise<LoudnessSettings> {
|
||||||
|
return api.put<LoudnessSettings>('/api/admin/library/loudness-settings', s);
|
||||||
|
}
|
||||||
|
|
||||||
// Cover-art providers ------------------------------------------------------
|
// Cover-art providers ------------------------------------------------------
|
||||||
|
|
||||||
export type CoverProviderCapability = 'album_cover' | 'artist_thumb' | 'artist_fanart';
|
export type CoverProviderCapability = 'album_cover' | 'artist_thumb' | 'artist_fanart';
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import {
|
||||||
|
getLoudnessCoverage,
|
||||||
|
getLoudnessSettings,
|
||||||
|
updateLoudnessSettings,
|
||||||
|
type LoudnessCoverage,
|
||||||
|
type LoudnessSettings
|
||||||
|
} from '$lib/api/admin';
|
||||||
|
import { errMessage } from '$lib/api/errors';
|
||||||
|
import { pushToast } from '$lib/stores/toast.svelte';
|
||||||
|
|
||||||
|
// Loudness analysis (#4995): the background measurement that loudness
|
||||||
|
// normalization levels playback from, its progress, and its two knobs.
|
||||||
|
|
||||||
|
let saved = $state<LoudnessSettings | null>(null);
|
||||||
|
let form = $state<LoudnessSettings | null>(null);
|
||||||
|
let coverage = $state<LoudnessCoverage | null>(null);
|
||||||
|
let saving = $state(false);
|
||||||
|
let loadError = $state(false);
|
||||||
|
|
||||||
|
const dirty = $derived(!!saved && !!form && JSON.stringify(saved) !== JSON.stringify(form));
|
||||||
|
const concurrencyOk = $derived(
|
||||||
|
!!form &&
|
||||||
|
Number.isInteger(form.backfill_concurrency) &&
|
||||||
|
form.backfill_concurrency >= 1 &&
|
||||||
|
form.backfill_concurrency <= 8
|
||||||
|
);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
saved = await getLoudnessSettings();
|
||||||
|
form = { ...saved };
|
||||||
|
loadError = false;
|
||||||
|
} catch {
|
||||||
|
loadError = true;
|
||||||
|
}
|
||||||
|
await refreshCoverage();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The gauge moves a few tracks a minute while the backfill runs, so a slow
|
||||||
|
// poll is enough; a failed read keeps the last figures rather than blanking.
|
||||||
|
async function refreshCoverage() {
|
||||||
|
try {
|
||||||
|
coverage = await getLoudnessCoverage();
|
||||||
|
} catch {
|
||||||
|
// Keep what we had.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
load();
|
||||||
|
const timer = setInterval(refreshCoverage, 30_000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!form || !concurrencyOk) return;
|
||||||
|
saving = true;
|
||||||
|
try {
|
||||||
|
saved = await updateLoudnessSettings(form);
|
||||||
|
form = { ...saved };
|
||||||
|
pushToast('Loudness analysis settings saved.');
|
||||||
|
await refreshCoverage();
|
||||||
|
} catch (e) {
|
||||||
|
pushToast(errMessage(e), 'error');
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputClass =
|
||||||
|
'mt-1 w-28 rounded border border-border bg-background px-2 py-1 text-sm text-text-primary ' +
|
||||||
|
'focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="space-y-4 rounded border border-border bg-surface p-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="font-display text-lg font-medium">Loudness analysis</h2>
|
||||||
|
<p class="mt-1 text-sm text-text-secondary">
|
||||||
|
Measures how loud each track is, so playback can even out the volume between tracks. It runs
|
||||||
|
in the background; until a track is measured, it plays at its own volume.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if coverage && coverage.total > 0}
|
||||||
|
<div class="flex flex-wrap items-center gap-3 text-sm" data-testid="loudness-coverage">
|
||||||
|
<span>
|
||||||
|
{coverage.measured.toLocaleString()} of {coverage.total.toLocaleString()} tracks measured
|
||||||
|
</span>
|
||||||
|
{#if coverage.pending > 0}
|
||||||
|
<span class="text-text-muted">·</span>
|
||||||
|
<span>{coverage.pending.toLocaleString()} pending</span>
|
||||||
|
{/if}
|
||||||
|
{#if !coverage.enabled && coverage.pending > 0}
|
||||||
|
<span class="text-text-muted">·</span>
|
||||||
|
<span>paused while analysis is off</span>
|
||||||
|
{/if}
|
||||||
|
{#if coverage.silent > 0}
|
||||||
|
<span class="text-text-muted">·</span>
|
||||||
|
<span
|
||||||
|
class="cursor-help"
|
||||||
|
title="These tracks are silent or too short to measure. They play without adjustment."
|
||||||
|
>
|
||||||
|
{coverage.silent.toLocaleString()} silent
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if coverage.unreadable > 0}
|
||||||
|
<span class="text-text-muted">·</span>
|
||||||
|
<span
|
||||||
|
class="cursor-help"
|
||||||
|
title="ffmpeg couldn't decode these files. Each is tried again when its file changes."
|
||||||
|
>
|
||||||
|
{coverage.unreadable.toLocaleString()} unreadable
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if loadError}
|
||||||
|
<p class="text-sm text-action-destructive">
|
||||||
|
Couldn't load loudness analysis settings.
|
||||||
|
<button type="button" class="underline hover:no-underline" onclick={load}>Try again</button>
|
||||||
|
</p>
|
||||||
|
{:else if form === null}
|
||||||
|
<p class="text-sm text-text-secondary">Loading…</p>
|
||||||
|
{:else}
|
||||||
|
<label class="flex items-start gap-3">
|
||||||
|
<input type="checkbox" bind:checked={form.enabled} class="mt-1" />
|
||||||
|
<span>
|
||||||
|
<span class="text-sm text-text-primary">Analyze tracks</span>
|
||||||
|
<span class="block text-xs text-text-secondary">
|
||||||
|
Off stops the background analysis, including for new tracks. Tracks already measured keep
|
||||||
|
their values, so they stay leveled.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="block">
|
||||||
|
<span class="text-sm text-text-primary">Files analyzed at once</span>
|
||||||
|
<span class="block text-xs text-text-secondary">
|
||||||
|
Each analysis reads the whole file, competing with playback for CPU and with streaming for
|
||||||
|
the disk. Lower it if playback stutters while it runs.
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="8"
|
||||||
|
bind:value={form.backfill_concurrency}
|
||||||
|
class={inputClass}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{#if !concurrencyOk}
|
||||||
|
<p class="text-xs text-action-destructive" data-testid="settings-problems">
|
||||||
|
Files analyzed at once must be a whole number from 1 to 8.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-md bg-action-secondary px-4 py-2 text-sm text-action-fg hover:opacity-90
|
||||||
|
focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent
|
||||||
|
disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
disabled={!dirty || saving || !concurrencyOk}
|
||||||
|
onclick={save}
|
||||||
|
>
|
||||||
|
{saving ? 'Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||||||
|
import type { LoudnessCoverage, LoudnessSettings } from '$lib/api/admin';
|
||||||
|
|
||||||
|
vi.mock('$lib/api/admin', () => ({
|
||||||
|
getLoudnessSettings: vi.fn(),
|
||||||
|
updateLoudnessSettings: vi.fn(),
|
||||||
|
getLoudnessCoverage: vi.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('$lib/stores/toast.svelte', () => ({ pushToast: vi.fn() }));
|
||||||
|
|
||||||
|
import LoudnessSettingsCard from './LoudnessSettingsCard.svelte';
|
||||||
|
import { getLoudnessCoverage, getLoudnessSettings, updateLoudnessSettings } from '$lib/api/admin';
|
||||||
|
import { pushToast } from '$lib/stores/toast.svelte';
|
||||||
|
|
||||||
|
const base: LoudnessSettings = { enabled: true, backfill_concurrency: 2 };
|
||||||
|
const coverage: LoudnessCoverage = {
|
||||||
|
total: 1200,
|
||||||
|
measured: 900,
|
||||||
|
silent: 2,
|
||||||
|
unreadable: 3,
|
||||||
|
pending: 295,
|
||||||
|
enabled: true
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
async function renderCard(
|
||||||
|
over: Partial<LoudnessSettings> = {},
|
||||||
|
cov: Partial<LoudnessCoverage> = {}
|
||||||
|
) {
|
||||||
|
vi.mocked(getLoudnessSettings).mockResolvedValue({ ...base, ...over });
|
||||||
|
vi.mocked(getLoudnessCoverage).mockResolvedValue({ ...coverage, ...cov });
|
||||||
|
const r = render(LoudnessSettingsCard);
|
||||||
|
await screen.findByRole('spinbutton', { name: /files analyzed at once/i });
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveButton = () => screen.getByRole('button', { name: /save/i });
|
||||||
|
const concurrency = () => screen.getByRole('spinbutton', { name: /files analyzed at once/i });
|
||||||
|
|
||||||
|
describe('LoudnessSettingsCard', () => {
|
||||||
|
test('shows how far the analysis has got', async () => {
|
||||||
|
await renderCard();
|
||||||
|
const gauge = await screen.findByTestId('loudness-coverage');
|
||||||
|
expect(gauge.textContent).toMatch(/900 of 1,200 tracks measured/);
|
||||||
|
expect(gauge.textContent).toMatch(/295 pending/);
|
||||||
|
expect(gauge.textContent).toMatch(/2 silent/);
|
||||||
|
expect(gauge.textContent).toMatch(/3 unreadable/);
|
||||||
|
expect(gauge.textContent).not.toMatch(/paused/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// With analysis off, pending never shrinks; the gauge must not read as progress.
|
||||||
|
test('says the work is paused when analysis is off', async () => {
|
||||||
|
await renderCard({ enabled: false }, { enabled: false });
|
||||||
|
const gauge = await screen.findByTestId('loudness-coverage');
|
||||||
|
expect(gauge.textContent).toMatch(/paused while analysis is off/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('save is disabled until something changes, then saves the form', async () => {
|
||||||
|
vi.mocked(updateLoudnessSettings).mockResolvedValue({ ...base, backfill_concurrency: 4 });
|
||||||
|
await renderCard();
|
||||||
|
expect(saveButton()).toHaveProperty('disabled', true);
|
||||||
|
|
||||||
|
await fireEvent.input(concurrency(), { target: { value: '4' } });
|
||||||
|
await waitFor(() => expect(saveButton()).toHaveProperty('disabled', false));
|
||||||
|
await fireEvent.click(saveButton());
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(updateLoudnessSettings).toHaveBeenCalledWith({ enabled: true, backfill_concurrency: 4 })
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(pushToast).toHaveBeenCalledWith('Loudness analysis settings saved.'));
|
||||||
|
// The gauge is read again after a save: switching analysis on or off changes it.
|
||||||
|
expect(getLoudnessCoverage).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an out-of-range value is named and cannot be saved', async () => {
|
||||||
|
await renderCard();
|
||||||
|
await fireEvent.input(concurrency(), { target: { value: '9' } });
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByTestId('settings-problems').textContent).toMatch(/1 to 8/)
|
||||||
|
);
|
||||||
|
expect(saveButton()).toHaveProperty('disabled', true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a failed load offers a retry', async () => {
|
||||||
|
vi.mocked(getLoudnessSettings).mockRejectedValue(new Error('boom'));
|
||||||
|
vi.mocked(getLoudnessCoverage).mockResolvedValue(coverage);
|
||||||
|
render(LoudnessSettingsCard);
|
||||||
|
await screen.findByText(/couldn't load loudness analysis settings/i);
|
||||||
|
expect(screen.getByRole('button', { name: /try again/i })).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
import { pageTitle } from '$lib/branding';
|
import { pageTitle } from '$lib/branding';
|
||||||
import { Disc3, Album, Music2, Check, X, RotateCcw, Trash2, Cloud, ChevronRight } from 'lucide-svelte';
|
import { Disc3, Album, Music2, Check, X, RotateCcw, Trash2, Cloud, ChevronRight } from 'lucide-svelte';
|
||||||
import StageBadge from '$lib/components/StageBadge.svelte';
|
import StageBadge from '$lib/components/StageBadge.svelte';
|
||||||
|
import LoudnessSettingsCard from '$lib/components/LoudnessSettingsCard.svelte';
|
||||||
import { stageState } from './stage-state';
|
import { stageState } from './stage-state';
|
||||||
import { useQueryClient } from '@tanstack/svelte-query';
|
import { useQueryClient } from '@tanstack/svelte-query';
|
||||||
import {
|
import {
|
||||||
@@ -463,6 +464,10 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Loudness analysis (#4995): a background worker like the fingerprint
|
||||||
|
backfill, so its progress lives on its own card. -->
|
||||||
|
<LoudnessSettingsCard />
|
||||||
|
|
||||||
<!-- Cover art bulk refetch -->
|
<!-- Cover art bulk refetch -->
|
||||||
<section class="rounded border border-border bg-surface p-4">
|
<section class="rounded border border-border bg-surface p-4">
|
||||||
<h2 class="font-display text-lg font-medium">Cover art</h2>
|
<h2 class="font-display text-lg font-medium">Cover art</h2>
|
||||||
|
|||||||
@@ -48,7 +48,13 @@ vi.mock('$lib/api/admin', async () => {
|
|||||||
deleteQuarantineViaLidarr: vi.fn().mockResolvedValue({}),
|
deleteQuarantineViaLidarr: vi.fn().mockResolvedValue({}),
|
||||||
triggerScan: vi.fn().mockResolvedValue({}),
|
triggerScan: vi.fn().mockResolvedValue({}),
|
||||||
refetchMissingCovers: vi.fn().mockResolvedValue({ started: true }),
|
refetchMissingCovers: vi.fn().mockResolvedValue({ started: true }),
|
||||||
researchMissingArt: vi.fn().mockResolvedValue({ version: 1 })
|
researchMissingArt: vi.fn().mockResolvedValue({ version: 1 }),
|
||||||
|
// LoudnessSettingsCard, rendered by the page and tested on its own.
|
||||||
|
getLoudnessSettings: vi.fn().mockResolvedValue({ enabled: true, backfill_concurrency: 2 }),
|
||||||
|
updateLoudnessSettings: vi.fn(),
|
||||||
|
getLoudnessCoverage: vi.fn().mockResolvedValue({
|
||||||
|
total: 0, measured: 0, silent: 0, unreadable: 0, pending: 0, enabled: true
|
||||||
|
})
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user