feat(library): measure every track's loudness in the background (M464 #4995)
release / go (push) Failing after 1m18s
release / web (push) Successful in 1m27s
release / govulncheck (push) Successful in 35s
release / integration (push) Failing after 4m13s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / android (push) Canceled after 5m47s
release / Build signed APK (releases and dev) (push) Canceled after 4m21s

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 13:01:01 -04:00
co-authored by Claude Opus 5.5
parent edd9a3a6db
commit f3196b3443
22 changed files with 2139 additions and 3 deletions
@@ -0,0 +1,173 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
getLoudnessCoverage,
getLoudnessSettings,
updateLoudnessSettings,
type LoudnessCoverage,
type LoudnessSettings
} from '$lib/api/admin';
import { errMessage } from '$lib/api/errors';
import { pushToast } from '$lib/stores/toast.svelte';
// Loudness analysis (#4995): the background measurement that loudness
// normalization levels playback from, its progress, and its two knobs.
let saved = $state<LoudnessSettings | null>(null);
let form = $state<LoudnessSettings | null>(null);
let coverage = $state<LoudnessCoverage | null>(null);
let saving = $state(false);
let loadError = $state(false);
const dirty = $derived(!!saved && !!form && JSON.stringify(saved) !== JSON.stringify(form));
const concurrencyOk = $derived(
!!form &&
Number.isInteger(form.backfill_concurrency) &&
form.backfill_concurrency >= 1 &&
form.backfill_concurrency <= 8
);
async function load() {
try {
saved = await getLoudnessSettings();
form = { ...saved };
loadError = false;
} catch {
loadError = true;
}
await refreshCoverage();
}
// The gauge moves a few tracks a minute while the backfill runs, so a slow
// poll is enough; a failed read keeps the last figures rather than blanking.
async function refreshCoverage() {
try {
coverage = await getLoudnessCoverage();
} catch {
// Keep what we had.
}
}
onMount(() => {
load();
const timer = setInterval(refreshCoverage, 30_000);
return () => clearInterval(timer);
});
async function save() {
if (!form || !concurrencyOk) return;
saving = true;
try {
saved = await updateLoudnessSettings(form);
form = { ...saved };
pushToast('Loudness analysis settings saved.');
await refreshCoverage();
} catch (e) {
pushToast(errMessage(e), 'error');
} finally {
saving = false;
}
}
const inputClass =
'mt-1 w-28 rounded border border-border bg-background px-2 py-1 text-sm text-text-primary ' +
'focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent';
</script>
<section class="space-y-4 rounded border border-border bg-surface p-4">
<div>
<h2 class="font-display text-lg font-medium">Loudness analysis</h2>
<p class="mt-1 text-sm text-text-secondary">
Measures how loud each track is, so playback can even out the volume between tracks. It runs
in the background; until a track is measured, it plays at its own volume.
</p>
</div>
{#if coverage && coverage.total > 0}
<div class="flex flex-wrap items-center gap-3 text-sm" data-testid="loudness-coverage">
<span>
{coverage.measured.toLocaleString()} of {coverage.total.toLocaleString()} tracks measured
</span>
{#if coverage.pending > 0}
<span class="text-text-muted">·</span>
<span>{coverage.pending.toLocaleString()} pending</span>
{/if}
{#if !coverage.enabled && coverage.pending > 0}
<span class="text-text-muted">·</span>
<span>paused while analysis is off</span>
{/if}
{#if coverage.silent > 0}
<span class="text-text-muted">·</span>
<span
class="cursor-help"
title="These tracks are silent or too short to measure. They play without adjustment."
>
{coverage.silent.toLocaleString()} silent
</span>
{/if}
{#if coverage.unreadable > 0}
<span class="text-text-muted">·</span>
<span
class="cursor-help"
title="ffmpeg couldn't decode these files. Each is tried again when its file changes."
>
{coverage.unreadable.toLocaleString()} unreadable
</span>
{/if}
</div>
{/if}
{#if loadError}
<p class="text-sm text-action-destructive">
Couldn't load loudness analysis settings.
<button type="button" class="underline hover:no-underline" onclick={load}>Try again</button>
</p>
{:else if form === null}
<p class="text-sm text-text-secondary">Loading…</p>
{:else}
<label class="flex items-start gap-3">
<input type="checkbox" bind:checked={form.enabled} class="mt-1" />
<span>
<span class="text-sm text-text-primary">Analyze tracks</span>
<span class="block text-xs text-text-secondary">
Off stops the background analysis, including for new tracks. Tracks already measured keep
their values, so they stay leveled.
</span>
</span>
</label>
<label class="block">
<span class="text-sm text-text-primary">Files analyzed at once</span>
<span class="block text-xs text-text-secondary">
Each analysis reads the whole file, competing with playback for CPU and with streaming for
the disk. Lower it if playback stutters while it runs.
</span>
<input
type="number"
min="1"
max="8"
bind:value={form.backfill_concurrency}
class={inputClass}
/>
</label>
{#if !concurrencyOk}
<p class="text-xs text-action-destructive" data-testid="settings-problems">
Files analyzed at once must be a whole number from 1 to 8.
</p>
{/if}
<div class="flex justify-end">
<button
type="button"
class="rounded-md bg-action-secondary px-4 py-2 text-sm text-action-fg hover:opacity-90
focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent
disabled:cursor-not-allowed disabled:opacity-50"
disabled={!dirty || saving || !concurrencyOk}
onclick={save}
>
{saving ? 'Saving…' : 'Save'}
</button>
</div>
{/if}
</section>