Files
minstrel/web/src/lib/components/LoudnessSettingsCard.svelte
T
bvandeusenandClaude Opus 5.5 f34423a0e0
release / go (push) Successful in 2m17s
release / govulncheck (push) Successful in 26s
release / web (push) Successful in 2m4s
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 / integration (push) Canceled after 4m39s
release / android (push) Canceled after 2m53s
release / Build signed APK (releases and dev) (push) Canceled after 2m24s
feat: leveled FLAC stream for Sonos/UPnP speakers (M464 #5001)
Speakers fetch their own audio, so the phone cannot level it. A cast
token minted with level=true (and the client's asAlbum, which only the
queue holder knows) now returns GET /api/tracks/{id}/leveled.flac: the
track rendered by ffmpeg at the user's gain (volume=XdB, plus
alimiter at -1 dBFS for a limiter-mode boost), metadata stripped, FLAC
at 16 or 24 bits and at most 48 kHz. The gain is computed server-side
from the user's preference and the stored loudness, carried as
?g=<centi-dB>&lim=0|1 and signed into the token, so an edited URL does
not verify. Unity gains get the plain stream.

Renders are written beside the cache file and renamed in, keyed by the
source's size and mtime, coalesced per file (singleflight, detached
from the requesting speaker so a retry finds the render running),
started at mint time so the fetch finds them ready, and evicted least
recently used past leveled_cache_mb, a new admin setting (migration
0068, Loudness analysis card).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 19:31:07 -04:00

198 lines
6.5 KiB
Svelte

<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 knobs. The
// cache size bounds the leveled copies rendered for speakers (#5001).
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
);
const cacheOk = $derived(
!!form &&
Number.isInteger(form.leveled_cache_mb) &&
form.leveled_cache_mb >= 256 &&
form.leveled_cache_mb <= 65536
);
const valid = $derived(concurrencyOk && cacheOk);
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 || !valid) 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>
<label class="block">
<span class="text-sm text-text-primary">Speaker cache (MB)</span>
<span class="block text-xs text-text-secondary">
Leveled copies for Sonos and UPnP speakers. The least recently played go first.
</span>
<input
type="number"
min="256"
max="65536"
step="256"
bind:value={form.leveled_cache_mb}
class={inputClass}
/>
</label>
{#if !valid}
<div class="text-xs text-action-destructive" data-testid="settings-problems">
{#if !concurrencyOk}<p>Files analyzed at once must be a whole number from 1 to 8.</p>{/if}
{#if !cacheOk}<p>Speaker cache must be a whole number of MB from 256 to 65536.</p>{/if}
</div>
{/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 || !valid}
onclick={save}
>
{saving ? 'Saving…' : 'Save'}
</button>
</div>
{/if}
</section>