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
+32
View File
@@ -364,6 +364,38 @@ export async function updateFingerprintSettings(
return api.put<FingerprintSettings>('/api/admin/library/fingerprint-settings', s);
}
// Loudness analysis (#4995) -------------------------------------------------
export type LoudnessCoverage = {
total: number;
measured: number;
// Read fine, but no part of the track was loud enough to measure: silence,
// or a file shorter than half a second.
silent: number;
unreadable: number;
pending: number;
// False when the operator has switched analysis off: pending then never
// shrinks, and nothing should read as progress.
enabled: boolean;
};
export async function getLoudnessCoverage(): Promise<LoudnessCoverage> {
return api.get<LoudnessCoverage>('/api/admin/library/loudness');
}
export type LoudnessSettings = {
enabled: boolean;
backfill_concurrency: number;
};
export async function getLoudnessSettings(): Promise<LoudnessSettings> {
return api.get<LoudnessSettings>('/api/admin/library/loudness-settings');
}
export async function updateLoudnessSettings(s: LoudnessSettings): Promise<LoudnessSettings> {
return api.put<LoudnessSettings>('/api/admin/library/loudness-settings', s);
}
// Cover-art providers ------------------------------------------------------
export type CoverProviderCapability = 'album_cover' | 'artist_thumb' | 'artist_fanart';
@@ -0,0 +1,173 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
getLoudnessCoverage,
getLoudnessSettings,
updateLoudnessSettings,
type LoudnessCoverage,
type LoudnessSettings
} from '$lib/api/admin';
import { errMessage } from '$lib/api/errors';
import { pushToast } from '$lib/stores/toast.svelte';
// Loudness analysis (#4995): the background measurement that loudness
// normalization levels playback from, its progress, and its two knobs.
let saved = $state<LoudnessSettings | null>(null);
let form = $state<LoudnessSettings | null>(null);
let coverage = $state<LoudnessCoverage | null>(null);
let saving = $state(false);
let loadError = $state(false);
const dirty = $derived(!!saved && !!form && JSON.stringify(saved) !== JSON.stringify(form));
const concurrencyOk = $derived(
!!form &&
Number.isInteger(form.backfill_concurrency) &&
form.backfill_concurrency >= 1 &&
form.backfill_concurrency <= 8
);
async function load() {
try {
saved = await getLoudnessSettings();
form = { ...saved };
loadError = false;
} catch {
loadError = true;
}
await refreshCoverage();
}
// The gauge moves a few tracks a minute while the backfill runs, so a slow
// poll is enough; a failed read keeps the last figures rather than blanking.
async function refreshCoverage() {
try {
coverage = await getLoudnessCoverage();
} catch {
// Keep what we had.
}
}
onMount(() => {
load();
const timer = setInterval(refreshCoverage, 30_000);
return () => clearInterval(timer);
});
async function save() {
if (!form || !concurrencyOk) return;
saving = true;
try {
saved = await updateLoudnessSettings(form);
form = { ...saved };
pushToast('Loudness analysis settings saved.');
await refreshCoverage();
} catch (e) {
pushToast(errMessage(e), 'error');
} finally {
saving = false;
}
}
const inputClass =
'mt-1 w-28 rounded border border-border bg-background px-2 py-1 text-sm text-text-primary ' +
'focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent';
</script>
<section class="space-y-4 rounded border border-border bg-surface p-4">
<div>
<h2 class="font-display text-lg font-medium">Loudness analysis</h2>
<p class="mt-1 text-sm text-text-secondary">
Measures how loud each track is, so playback can even out the volume between tracks. It runs
in the background; until a track is measured, it plays at its own volume.
</p>
</div>
{#if coverage && coverage.total > 0}
<div class="flex flex-wrap items-center gap-3 text-sm" data-testid="loudness-coverage">
<span>
{coverage.measured.toLocaleString()} of {coverage.total.toLocaleString()} tracks measured
</span>
{#if coverage.pending > 0}
<span class="text-text-muted">·</span>
<span>{coverage.pending.toLocaleString()} pending</span>
{/if}
{#if !coverage.enabled && coverage.pending > 0}
<span class="text-text-muted">·</span>
<span>paused while analysis is off</span>
{/if}
{#if coverage.silent > 0}
<span class="text-text-muted">·</span>
<span
class="cursor-help"
title="These tracks are silent or too short to measure. They play without adjustment."
>
{coverage.silent.toLocaleString()} silent
</span>
{/if}
{#if coverage.unreadable > 0}
<span class="text-text-muted">·</span>
<span
class="cursor-help"
title="ffmpeg couldn't decode these files. Each is tried again when its file changes."
>
{coverage.unreadable.toLocaleString()} unreadable
</span>
{/if}
</div>
{/if}
{#if loadError}
<p class="text-sm text-action-destructive">
Couldn't load loudness analysis settings.
<button type="button" class="underline hover:no-underline" onclick={load}>Try again</button>
</p>
{:else if form === null}
<p class="text-sm text-text-secondary">Loading…</p>
{:else}
<label class="flex items-start gap-3">
<input type="checkbox" bind:checked={form.enabled} class="mt-1" />
<span>
<span class="text-sm text-text-primary">Analyze tracks</span>
<span class="block text-xs text-text-secondary">
Off stops the background analysis, including for new tracks. Tracks already measured keep
their values, so they stay leveled.
</span>
</span>
</label>
<label class="block">
<span class="text-sm text-text-primary">Files analyzed at once</span>
<span class="block text-xs text-text-secondary">
Each analysis reads the whole file, competing with playback for CPU and with streaming for
the disk. Lower it if playback stutters while it runs.
</span>
<input
type="number"
min="1"
max="8"
bind:value={form.backfill_concurrency}
class={inputClass}
/>
</label>
{#if !concurrencyOk}
<p class="text-xs text-action-destructive" data-testid="settings-problems">
Files analyzed at once must be a whole number from 1 to 8.
</p>
{/if}
<div class="flex justify-end">
<button
type="button"
class="rounded-md bg-action-secondary px-4 py-2 text-sm text-action-fg hover:opacity-90
focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent
disabled:cursor-not-allowed disabled:opacity-50"
disabled={!dirty || saving || !concurrencyOk}
onclick={save}
>
{saving ? 'Saving…' : 'Save'}
</button>
</div>
{/if}
</section>
@@ -0,0 +1,94 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
import type { LoudnessCoverage, LoudnessSettings } from '$lib/api/admin';
vi.mock('$lib/api/admin', () => ({
getLoudnessSettings: vi.fn(),
updateLoudnessSettings: vi.fn(),
getLoudnessCoverage: vi.fn()
}));
vi.mock('$lib/stores/toast.svelte', () => ({ pushToast: vi.fn() }));
import LoudnessSettingsCard from './LoudnessSettingsCard.svelte';
import { getLoudnessCoverage, getLoudnessSettings, updateLoudnessSettings } from '$lib/api/admin';
import { pushToast } from '$lib/stores/toast.svelte';
const base: LoudnessSettings = { enabled: true, backfill_concurrency: 2 };
const coverage: LoudnessCoverage = {
total: 1200,
measured: 900,
silent: 2,
unreadable: 3,
pending: 295,
enabled: true
};
afterEach(() => vi.clearAllMocks());
async function renderCard(
over: Partial<LoudnessSettings> = {},
cov: Partial<LoudnessCoverage> = {}
) {
vi.mocked(getLoudnessSettings).mockResolvedValue({ ...base, ...over });
vi.mocked(getLoudnessCoverage).mockResolvedValue({ ...coverage, ...cov });
const r = render(LoudnessSettingsCard);
await screen.findByRole('spinbutton', { name: /files analyzed at once/i });
return r;
}
const saveButton = () => screen.getByRole('button', { name: /save/i });
const concurrency = () => screen.getByRole('spinbutton', { name: /files analyzed at once/i });
describe('LoudnessSettingsCard', () => {
test('shows how far the analysis has got', async () => {
await renderCard();
const gauge = await screen.findByTestId('loudness-coverage');
expect(gauge.textContent).toMatch(/900 of 1,200 tracks measured/);
expect(gauge.textContent).toMatch(/295 pending/);
expect(gauge.textContent).toMatch(/2 silent/);
expect(gauge.textContent).toMatch(/3 unreadable/);
expect(gauge.textContent).not.toMatch(/paused/);
});
// With analysis off, pending never shrinks; the gauge must not read as progress.
test('says the work is paused when analysis is off', async () => {
await renderCard({ enabled: false }, { enabled: false });
const gauge = await screen.findByTestId('loudness-coverage');
expect(gauge.textContent).toMatch(/paused while analysis is off/);
});
test('save is disabled until something changes, then saves the form', async () => {
vi.mocked(updateLoudnessSettings).mockResolvedValue({ ...base, backfill_concurrency: 4 });
await renderCard();
expect(saveButton()).toHaveProperty('disabled', true);
await fireEvent.input(concurrency(), { target: { value: '4' } });
await waitFor(() => expect(saveButton()).toHaveProperty('disabled', false));
await fireEvent.click(saveButton());
await waitFor(() =>
expect(updateLoudnessSettings).toHaveBeenCalledWith({ enabled: true, backfill_concurrency: 4 })
);
await waitFor(() => expect(pushToast).toHaveBeenCalledWith('Loudness analysis settings saved.'));
// The gauge is read again after a save: switching analysis on or off changes it.
expect(getLoudnessCoverage).toHaveBeenCalledTimes(2);
});
test('an out-of-range value is named and cannot be saved', async () => {
await renderCard();
await fireEvent.input(concurrency(), { target: { value: '9' } });
await waitFor(() =>
expect(screen.getByTestId('settings-problems').textContent).toMatch(/1 to 8/)
);
expect(saveButton()).toHaveProperty('disabled', true);
});
test('a failed load offers a retry', async () => {
vi.mocked(getLoudnessSettings).mockRejectedValue(new Error('boom'));
vi.mocked(getLoudnessCoverage).mockResolvedValue(coverage);
render(LoudnessSettingsCard);
await screen.findByText(/couldn't load loudness analysis settings/i);
expect(screen.getByRole('button', { name: /try again/i })).toBeTruthy();
});
});
+5
View File
@@ -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}
</section>
<!-- Loudness analysis (#4995): a background worker like the fingerprint
backfill, so its progress lives on its own card. -->
<LoudnessSettingsCard />
<!-- Cover art bulk refetch -->
<section class="rounded border border-border bg-surface p-4">
<h2 class="font-display text-lg font-medium">Cover art</h2>
+7 -1
View File
@@ -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
})
};
});