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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user