diff --git a/cmd/minstrel/main.go b/cmd/minstrel/main.go index a616e673..7652bd2f 100644 --- a/cmd/minstrel/main.go +++ b/cmd/minstrel/main.go @@ -132,6 +132,13 @@ func run() error { } 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 if contact == "" { contact = "https://git.fabledsword.com/bvandeusen/minstrel" @@ -228,6 +235,11 @@ func run() error { // internal/library/fingerprint_backfill.go for why. 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 // recording, from the fingerprints above. Sweeps only when fingerprints have // changed since the last sweep. @@ -377,6 +389,7 @@ func run() error { srv.RecSettings = recSettings srv.TagSettings = tagSettings srv.FingerprintSettings = fpSettings + srv.LoudnessSettings = loudSettings // The sweeper above holds this same instance, so a save from the admin // card changes what it does on its next tick (#3936). srv.ReacqSettings = reacqSettings diff --git a/internal/api/admin_loudness.go b/internal/api/admin_loudness.go new file mode 100644 index 00000000..13cf850d --- /dev/null +++ b/internal/api/admin_loudness.go @@ -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)) +} diff --git a/internal/api/api.go b/internal/api/api.go index cdbff7db..65d4240b 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -34,7 +34,7 @@ import ( // Mount attaches /api/* handlers to r. Public endpoints (login) are outside // RequireUser; everything else is gated by the middleware. The events writer // is shared with the Subsonic mount so /rest/scrobble feeds the same store. -func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, recSettings *recsettings.Service, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, tagSettings *tags.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte, netSettings *netsettings.Service, reacqSettings *reacquisition.SettingsService, fpSettings *library.FingerprintSettingsService) { +func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, recSettings *recsettings.Service, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, tagSettings *tags.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte, netSettings *netsettings.Service, reacqSettings *reacquisition.SettingsService, fpSettings *library.FingerprintSettingsService, loudSettings *library.LoudnessSettingsService) { rng := rand.New(rand.NewSource(rand.Int63())) setupToken, err := auth.NewSetupToken() if err != nil { @@ -65,6 +65,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev netSettings: netSettings, reacqSettings: reacqSettings, fingerprintSettings: fpSettings, + loudnessSettings: loudSettings, librarySize: recommendation.NewLibrarySize(nil), loginGuard: auth.NewLoginGuard(), setupToken: setupToken, @@ -237,6 +238,9 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev admin.Get("/library/fingerprints", h.handleGetFingerprintCoverage) admin.Get("/library/fingerprint-settings", h.handleGetFingerprintSettings) admin.Put("/library/fingerprint-settings", h.handleUpdateFingerprintSettings) + admin.Get("/library/loudness", h.handleGetLoudnessCoverage) + admin.Get("/library/loudness-settings", h.handleGetLoudnessSettings) + admin.Put("/library/loudness-settings", h.handleUpdateLoudnessSettings) // Duplicates report (#3912): proposals from the duplicate sweep, a // trigger to sweep now, dismissal, and the merge (#3911), which deletes // the removed copies' files after moving their history onto the kept one. @@ -331,6 +335,9 @@ type handlers struct { // instance the scanner and the fingerprint workers read, so a save from the // admin card reaches them without a restart. Nil serves the defaults. fingerprintSettings *library.FingerprintSettingsService + // loudnessSettings is the loudness analysis policy (M464 #4995), the same + // instance the loudness backfill reads. Nil serves the defaults. + loudnessSettings *library.LoudnessSettingsService // setupToken must accompany the first registration while no users exist // (see auth.SetupToken). requireSetupToken is set by Mount, the only // production constructor; tests that build handlers directly leave it diff --git a/internal/db/dbq/loudness.sql.go b/internal/db/dbq/loudness.sql.go new file mode 100644 index 00000000..1c05ba61 --- /dev/null +++ b/internal/db/dbq/loudness.sql.go @@ -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 +} diff --git a/internal/db/dbq/models.go b/internal/db/dbq/models.go index 88b1901d..99b5cbf7 100644 --- a/internal/db/dbq/models.go +++ b/internal/db/dbq/models.go @@ -417,6 +417,13 @@ type LidarrRequest struct { LidarrAddConfirmedAt pgtype.Timestamptz } +type LoudnessSetting struct { + ID bool + Enabled bool + BackfillConcurrency int32 + UpdatedAt pgtype.Timestamptz +} + type MissingReacquisition struct { AlbumID pgtype.UUID Attempts int32 @@ -713,6 +720,18 @@ type TrackFingerprint struct { 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 { TrackAID pgtype.UUID TrackBID pgtype.UUID diff --git a/internal/db/migrations/0065_track_loudness.down.sql b/internal/db/migrations/0065_track_loudness.down.sql new file mode 100644 index 00000000..0bb9eced --- /dev/null +++ b/internal/db/migrations/0065_track_loudness.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS loudness_settings; +DROP TABLE IF EXISTS track_loudness; diff --git a/internal/db/migrations/0065_track_loudness.up.sql b/internal/db/migrations/0065_track_loudness.up.sql new file mode 100644 index 00000000..ae039dc6 --- /dev/null +++ b/internal/db/migrations/0065_track_loudness.up.sql @@ -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; diff --git a/internal/db/queries/loudness.sql b/internal/db/queries/loudness.sql new file mode 100644 index 00000000..821d0ad5 --- /dev/null +++ b/internal/db/queries/loudness.sql @@ -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 *; diff --git a/internal/dbtest/reset.go b/internal/dbtest/reset.go index a5b2380e..8d1558dc 100644 --- a/internal/dbtest/reset.go +++ b/internal/dbtest/reset.go @@ -91,6 +91,7 @@ var dataTables = []string{ "duplicate_groups", "duplicate_sweeps", "track_fingerprints", // M400 + "track_loudness", // M464 "tracks", "albums", "artists", @@ -141,4 +142,11 @@ func ResetDB(t *testing.T, pool *pgxpool.Pool) { ); err != nil { 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) + } } diff --git a/internal/library/loudness.go b/internal/library/loudness.go new file mode 100644 index 00000000..ffea3c9f --- /dev/null +++ b/internal/library/loudness.go @@ -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 +} diff --git a/internal/library/loudness_backfill.go b/internal/library/loudness_backfill.go new file mode 100644 index 00000000..8dc84dd0 --- /dev/null +++ b/internal/library/loudness_backfill.go @@ -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) +} diff --git a/internal/library/loudness_backfill_test.go b/internal/library/loudness_backfill_test.go new file mode 100644 index 00000000..4a052bec --- /dev/null +++ b/internal/library/loudness_backfill_test.go @@ -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) + } +} diff --git a/internal/library/loudness_settings.go b/internal/library/loudness_settings.go new file mode 100644 index 00000000..31beca20 --- /dev/null +++ b/internal/library/loudness_settings.go @@ -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, + } +} diff --git a/internal/library/loudness_test.go b/internal/library/loudness_test.go new file mode 100644 index 00000000..d2f40704 --- /dev/null +++ b/internal/library/loudness_test.go @@ -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) + } +} diff --git a/internal/library/scanner.go b/internal/library/scanner.go index f2c00c32..c7d45dcd 100644 --- a/internal/library/scanner.go +++ b/internal/library/scanner.go @@ -402,6 +402,13 @@ func (s *Scanner) scanFile( // 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) } + // 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 { diff --git a/internal/library/testdata/ebur128_fixture.txt b/internal/library/testdata/ebur128_fixture.txt new file mode 100644 index 00000000..cf6eeb3f --- /dev/null +++ b/internal/library/testdata/ebur128_fixture.txt @@ -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 diff --git a/internal/server/server.go b/internal/server/server.go index efae1029..c833c508 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -105,6 +105,9 @@ type Server struct { // fingerprint workers, so a save from the admin card reaches them without a // restart. Router() constructs a fallback when nil (tests). 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 // (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 @@ -214,7 +217,15 @@ func (s *Server) Router() http.Handler { 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 // (it needs the Scanner). Register it as a single inline-middleware // route — using r.Route("/api/admin", ...) here would create a second diff --git a/web/src/lib/api/admin.ts b/web/src/lib/api/admin.ts index b1b86597..fa925e28 100644 --- a/web/src/lib/api/admin.ts +++ b/web/src/lib/api/admin.ts @@ -364,6 +364,38 @@ export async function updateFingerprintSettings( return api.put('/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 { + return api.get('/api/admin/library/loudness'); +} + +export type LoudnessSettings = { + enabled: boolean; + backfill_concurrency: number; +}; + +export async function getLoudnessSettings(): Promise { + return api.get('/api/admin/library/loudness-settings'); +} + +export async function updateLoudnessSettings(s: LoudnessSettings): Promise { + return api.put('/api/admin/library/loudness-settings', s); +} + // Cover-art providers ------------------------------------------------------ export type CoverProviderCapability = 'album_cover' | 'artist_thumb' | 'artist_fanart'; diff --git a/web/src/lib/components/LoudnessSettingsCard.svelte b/web/src/lib/components/LoudnessSettingsCard.svelte new file mode 100644 index 00000000..6003f1df --- /dev/null +++ b/web/src/lib/components/LoudnessSettingsCard.svelte @@ -0,0 +1,173 @@ + + +
+
+

Loudness analysis

+

+ 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. +

+
+ + {#if coverage && coverage.total > 0} +
+ + {coverage.measured.toLocaleString()} of {coverage.total.toLocaleString()} tracks measured + + {#if coverage.pending > 0} + · + {coverage.pending.toLocaleString()} pending + {/if} + {#if !coverage.enabled && coverage.pending > 0} + · + paused while analysis is off + {/if} + {#if coverage.silent > 0} + · + + {coverage.silent.toLocaleString()} silent + + {/if} + {#if coverage.unreadable > 0} + · + + {coverage.unreadable.toLocaleString()} unreadable + + {/if} +
+ {/if} + + {#if loadError} +

+ Couldn't load loudness analysis settings. + +

+ {:else if form === null} +

Loading…

+ {:else} + + + + + {#if !concurrencyOk} +

+ Files analyzed at once must be a whole number from 1 to 8. +

+ {/if} + +
+ +
+ {/if} +
diff --git a/web/src/lib/components/LoudnessSettingsCard.test.ts b/web/src/lib/components/LoudnessSettingsCard.test.ts new file mode 100644 index 00000000..670676ad --- /dev/null +++ b/web/src/lib/components/LoudnessSettingsCard.test.ts @@ -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 = {}, + cov: Partial = {} +) { + 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(); + }); +}); diff --git a/web/src/routes/admin/+page.svelte b/web/src/routes/admin/+page.svelte index 97feda2a..5cc0c1a2 100644 --- a/web/src/routes/admin/+page.svelte +++ b/web/src/routes/admin/+page.svelte @@ -2,6 +2,7 @@ import { pageTitle } from '$lib/branding'; import { Disc3, Album, Music2, Check, X, RotateCcw, Trash2, Cloud, ChevronRight } from 'lucide-svelte'; import StageBadge from '$lib/components/StageBadge.svelte'; + import LoudnessSettingsCard from '$lib/components/LoudnessSettingsCard.svelte'; import { stageState } from './stage-state'; import { useQueryClient } from '@tanstack/svelte-query'; import { @@ -463,6 +464,10 @@ {/if} + + +

Cover art

diff --git a/web/src/routes/admin/admin.test.ts b/web/src/routes/admin/admin.test.ts index d80205f0..04f80b6f 100644 --- a/web/src/routes/admin/admin.test.ts +++ b/web/src/routes/admin/admin.test.ts @@ -48,7 +48,13 @@ vi.mock('$lib/api/admin', async () => { deleteQuarantineViaLidarr: vi.fn().mockResolvedValue({}), triggerScan: vi.fn().mockResolvedValue({}), 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 + }) }; });