Files
minstrel/web/src/lib/components/LoudnessSettingsCard.test.ts
T
bvandeusenandClaude Opus 5.5 f3196b3443
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
feat(library): measure every track's loudness in the background (M464 #4995)
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>
2026-10-06 13:01:01 -04:00

95 lines
3.8 KiB
TypeScript

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();
});
});