feat(library): measure every track's loudness in the background (M464 #4995)
release / go (push) Failing after 1m18s
release / web (push) Successful in 1m27s
release / govulncheck (push) Successful in 35s
release / integration (push) Failing after 4m13s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / android (push) Canceled after 5m47s
release / Build signed APK (releases and dev) (push) Canceled after 4m21s
release / go (push) Failing after 1m18s
release / web (push) Successful in 1m27s
release / govulncheck (push) Successful in 35s
release / integration (push) Failing after 4m13s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / android (push) Canceled after 5m47s
release / Build signed APK (releases and dev) (push) Canceled after 4m21s
The first step of loudness normalization: the server measures each track with ffmpeg's EBU R128 filter (true peak, mono as dual mono) and stores the integrated loudness, true peak and loudness range in track_loudness (migration 0065). It also keeps a histogram of the 400 ms gating blocks at 0.1 LU, so album loudness can be computed exactly later with no second decode (#4996). The histogram reproduces ffmpeg's own figure (-10.68 against -10.7 on the captured fixture), and the analyzer logs a warning if the two ever drift. - A background worker, cloned from the fingerprint backfill, measures every track, new ones included. Measuring inline in the scan was dropped: the analysis decodes the whole file, and a large import could pass the scan's one-hour stuck threshold. The scan only deletes a changed file's measurement; the worker ticks every 10 minutes. - Timeouts, the cancel/missing-binary split and settled verdicts follow the fingerprint runner. Silence and undecodable files are stored as verdicts; stalls are retried. The deadline scales with track length. - loudness_settings (enabled, files at once) and an admin card with the coverage gauge, under GET/PUT /api/admin/library/loudness-settings and GET /api/admin/library/loudness. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user