feat(web): level playback by the user's normalization preference (M464 #4999)
release / govulncheck (push) Successful in 18s
release / web (push) Successful in 1m13s
release / go (push) Successful in 1m29s
release / integration (push) Successful in 4m26s
release / android (push) Successful in 5m20s
release / Build signed APK (releases and dev) (push) Successful in 5m31s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 32s
release / Verify release artifacts (tag releases only) (push) Skipped

Cuts go through element.volume. Boosts route the element through a Web
Audio GainNode and a DynamicsCompressor (a -1 dBFS limiter in limiter
mode, a pass-through otherwise), built only when a track wants a boost
and only once an AudioContext is confirmed running; iOS never gets the
graph. Auto mode takes album gain when a queue neighbour is from the
same album in track order. Gains are fetched for the next 50 tracks as
the queue moves, with a 10s deadline. The prefetch element is untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 18:21:41 -04:00
co-authored by Claude Opus 5.5
parent 2a7eb3dd19
commit 38290bf8f9
7 changed files with 434 additions and 3 deletions
+21 -1
View File
@@ -1,4 +1,4 @@
import { api } from './client'; import { api, apiFetch } from './client';
// Loudness normalization preference (M464 #4998). One per user, stored on // Loudness normalization preference (M464 #4998). One per user, stored on
// the server so the web player, the Android app and casts agree. // the server so the web player, the Android app and casts agree.
@@ -40,3 +40,23 @@ export function getNormalization(): Promise<NormalizationPrefs> {
export function putNormalization(p: NormalizationPrefs): Promise<NormalizationPrefs> { export function putNormalization(p: NormalizationPrefs): Promise<NormalizationPrefs> {
return api.put<NormalizationPrefs>('/api/me/normalization', p); return api.put<NormalizationPrefs>('/api/me/normalization', p);
} }
// Per-track gains (#4997): dB to the -18 LUFS ReplayGain reference and linear
// peaks. A field is absent until the server has measured it.
export type ReplayGain = {
track_gain?: number;
track_peak?: number;
album_gain?: number;
album_peak?: number;
};
/**
* Gains for up to 200 tracks; an id missing from items has none yet. Given
* a deadline: the player waits on it, and a request that never answers
* would leave those tracks unleveled for the rest of the session.
*/
export function getReplayGain(ids: string[]): Promise<{ items: Record<string, ReplayGain> }> {
return apiFetch(`/api/tracks/replay-gain?ids=${ids.map(encodeURIComponent).join(',')}`, {
signal: AbortSignal.timeout(10_000)
}) as Promise<{ items: Record<string, ReplayGain> }>;
}
+86
View File
@@ -0,0 +1,86 @@
import { describe, expect, test } from 'vitest';
import { dbToLinear, gainDb, playingAsAlbum, MAX_BOOST_DB } from './gain';
import type { NormalizationPrefs } from '$lib/api/normalization';
import type { TrackRef } from '$lib/api/types';
import { makeTrack } from '$test-utils/fixtures/track';
const prefs = (over: Partial<NormalizationPrefs> = {}): NormalizationPrefs => ({
mode: 'auto',
target_lufs: -18,
boost: 'headroom',
...over
});
const track = (id: string, album: string, n?: number, disc?: number): TrackRef =>
makeTrack({ id, album_id: album, track_number: n, disc_number: disc });
describe('gainDb', () => {
const g = { track_gain: -6, track_peak: 1, album_gain: -4, album_peak: 1 };
test('off and unmeasured tracks play as mastered', () => {
expect(gainDb(prefs({ mode: 'off' }), g, false)).toBe(0);
expect(gainDb(prefs(), undefined, false)).toBe(0);
expect(gainDb(prefs(), {}, false)).toBe(0);
});
test('mode picks the gain; auto follows album play', () => {
expect(gainDb(prefs({ mode: 'track' }), g, true)).toBe(-6);
expect(gainDb(prefs({ mode: 'album' }), g, false)).toBe(-4);
expect(gainDb(prefs({ mode: 'auto' }), g, true)).toBe(-4);
expect(gainDb(prefs({ mode: 'auto' }), g, false)).toBe(-6);
});
test('album gain falls back to track gain until the album is measured', () => {
expect(gainDb(prefs({ mode: 'album' }), { track_gain: -6, track_peak: 1 }, true)).toBe(-6);
});
test('a louder target raises every gain by the difference', () => {
expect(gainDb(prefs({ mode: 'track', target_lufs: -14 }), g, false)).toBe(-2);
});
test('headroom stops a boost 1 dB under the true peak; the limiter lets it through', () => {
// +8 dB wanted; the peak at 0.5 (about -6 dBTP) leaves 5 dB of room.
const quiet = { track_gain: 8, track_peak: 0.5 };
expect(gainDb(prefs({ mode: 'track' }), quiet, false)).toBeCloseTo(-1 - 20 * Math.log10(0.5), 5);
expect(gainDb(prefs({ mode: 'track', boost: 'limiter' }), quiet, false)).toBe(8);
});
test('no boost passes the cap, limiter or not', () => {
const silent = { track_gain: 30, track_peak: 0.001 };
expect(gainDb(prefs({ mode: 'track', boost: 'limiter' }), silent, false)).toBe(MAX_BOOST_DB);
});
test('cuts are never limited by the peak', () => {
expect(gainDb(prefs({ mode: 'track' }), { track_gain: -9, track_peak: 1.4 }, false)).toBe(-9);
});
});
describe('playingAsAlbum', () => {
test('in-order neighbours from the same album', () => {
const q = [track('1', 'x', 1), track('2', 'x', 2), track('3', 'x', 3)];
expect(playingAsAlbum(q, 0)).toBe(true);
expect(playingAsAlbum(q, 1)).toBe(true);
expect(playingAsAlbum(q, 2)).toBe(true);
});
test('a shuffled album, a mix and a lone track are not album play', () => {
expect(playingAsAlbum([track('3', 'x', 3), track('1', 'x', 1)], 0)).toBe(false);
expect(playingAsAlbum([track('1', 'x', 1), track('2', 'y', 2)], 0)).toBe(false);
expect(playingAsAlbum([track('1', 'x', 1)], 0)).toBe(false);
});
test('disc order counts: disc 2 track 1 follows disc 1 track 12', () => {
const q = [track('a', 'x', 12, 1), track('b', 'x', 1, 2)];
expect(playingAsAlbum(q, 1)).toBe(true);
});
test('neighbours without track numbers are not evidence of order', () => {
expect(playingAsAlbum([track('1', 'x'), track('2', 'x')], 0)).toBe(false);
});
});
test('dbToLinear', () => {
expect(dbToLinear(0)).toBe(1);
expect(dbToLinear(-20)).toBeCloseTo(0.1, 6);
expect(dbToLinear(6)).toBeCloseTo(1.995, 3);
});
+66
View File
@@ -0,0 +1,66 @@
// Loudness normalization math for the web player (M464 #4999). Pure, so the
// rules are tested without an <audio> element or an AudioContext.
import type { NormalizationPrefs, ReplayGain } from '$lib/api/normalization';
import type { TrackRef } from '$lib/api/types';
// The server's gains are to the ReplayGain 2.0 reference.
const REFERENCE_LUFS = -18;
// Headroom mode raises a quiet track only until its true peak reaches this.
export const PEAK_CEILING_DBTP = -1;
// No track is raised more than this, whatever its measurement says: a
// near-silent track would otherwise come out as amplified noise.
export const MAX_BOOST_DB = 12;
/**
* Whether the track at `index` is being played as part of its album, in
* order: a neighbour in the queue is from the same album and sits on the
* right side of it. That is when album gain keeps the album's own dynamics
* (a quiet intro stays quiet); anywhere else track gain levels the mix.
*/
export function playingAsAlbum(queue: TrackRef[], index: number): boolean {
const cur = queue[index];
if (!cur) return false;
const prev = queue[index - 1];
const next = queue[index + 1];
return (
(prev !== undefined && prev.album_id === cur.album_id && order(prev) < order(cur)) ||
(next !== undefined && next.album_id === cur.album_id && order(next) > order(cur))
);
}
// Disc-major track order. Tracks without numbers compare as equal and so
// never count as in order; a same-album neighbour with no numbers is not
// evidence of album play.
function order(t: TrackRef): number {
return (t.disc_number ?? 1) * 1000 + (t.track_number ?? 0);
}
/**
* The gain to apply, in dB, for one track. 0 when leveling is off or the
* track has not been measured yet: an unmeasured track plays as mastered.
*/
export function gainDb(
prefs: NormalizationPrefs,
g: ReplayGain | undefined,
asAlbum: boolean
): number {
if (prefs.mode === 'off' || !g) return 0;
const wantAlbum = prefs.mode === 'album' || (prefs.mode === 'auto' && asAlbum);
// Album gain falls back to track gain while the album is still being
// measured; track gain never falls back to album gain.
const useAlbum = wantAlbum && g.album_gain !== undefined;
const gain = useAlbum ? g.album_gain : g.track_gain;
const peak = useAlbum ? g.album_peak : g.track_peak;
if (gain === undefined) return 0;
let db = gain + (prefs.target_lufs - REFERENCE_LUFS);
if (prefs.boost === 'headroom' && peak !== undefined && peak > 0) {
db = Math.min(db, PEAK_CEILING_DBTP - 20 * Math.log10(peak));
}
return Math.min(db, MAX_BOOST_DB);
}
export function dbToLinear(db: number): number {
return Math.pow(10, db / 20);
}
@@ -0,0 +1,62 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
import type { TrackRef } from '$lib/api/types';
import { makeTrack } from '$test-utils/fixtures/track';
vi.mock('$lib/auth/store.svelte', () => ({
user: {
get value() {
return { id: 'test-user' };
}
}
}));
const getReplayGain = vi.fn();
vi.mock('$lib/api/normalization', async (orig) => ({
...(await orig<typeof import('$lib/api/normalization')>()),
getReplayGain: (ids: string[]) => getReplayGain(ids)
}));
const { playQueue } = await import('./store.svelte');
const { applyGain, ensureGains, gainDbAt, hasGraph } = await import('./gainStage.svelte');
const track = (id: string, n: number): TrackRef =>
makeTrack({ id, album_id: 'alb', track_number: n, disc_number: 1 });
beforeEach(() => {
getReplayGain.mockReset();
});
describe('gain stage', () => {
test('fetches each queued gain once; an unmeasured track stays at 0 dB', async () => {
getReplayGain.mockResolvedValue({
items: { a: { track_gain: -6, track_peak: 1, album_gain: -4, album_peak: 1 } }
});
playQueue([track('a', 1), track('b', 2)]);
await ensureGains();
await ensureGains();
expect(getReplayGain).toHaveBeenCalledTimes(1);
expect(getReplayGain).toHaveBeenCalledWith(['a', 'b']);
// In album order, so auto mode takes the album gain.
expect(gainDbAt(0)).toBe(-4);
expect(gainDbAt(1)).toBe(0);
});
test('a failed fetch is asked again on the next call', async () => {
getReplayGain.mockRejectedValueOnce(new Error('offline'));
playQueue([track('c', 1)]);
await ensureGains();
expect(gainDbAt(0)).toBe(0);
getReplayGain.mockResolvedValueOnce({ items: { c: { track_gain: -3, track_peak: 1 } } });
await ensureGains();
expect(gainDbAt(0)).toBe(-3);
});
test('without the graph a cut scales the element and a boost plays at unity', () => {
expect(hasGraph()).toBe(false);
const el = { volume: 1 } as HTMLAudioElement;
applyGain(el, 0.8, -6);
expect(el.volume).toBeCloseTo(0.8 * 0.501, 3);
applyGain(el, 0.8, 6);
expect(el.volume).toBe(0.8);
});
});
+152
View File
@@ -0,0 +1,152 @@
// The web player's gain stage (M464 #4999). A cut is applied through the
// element's own volume. A boost needs Web Audio, because element.volume
// stops at 1: the element is routed through a GainNode and a
// DynamicsCompressor, which acts as a limiter when the user picked one.
//
// Routing is permanent once made (createMediaElementSource cannot be
// undone), and a context that is not running plays nothing. So the graph is
// built only when a track actually wants a boost, and only once a context
// has been confirmed running. Until then a boost plays at unity, which is
// what the track sounded like before leveling existed.
import { SvelteMap } from 'svelte/reactivity';
import { getReplayGain, type ReplayGain } from '$lib/api/normalization';
import { normalization } from '$lib/stores/normalization.svelte';
import { player } from './store.svelte';
import { dbToLinear, gainDb, playingAsAlbum } from './gain';
// Gains fetched so far; null means the server has none for that track yet.
// Kept for the page's life: a track measured later is picked up on reload.
const gains = new SvelteMap<string, ReplayGain | null>();
const inflight = new Set<string>();
// How far ahead of the current track gains are fetched, so a skip or the
// next track has its gain before it starts playing.
const LOOKAHEAD = 50;
const BATCH = 200; // the endpoint's limit
const stage = $state({ graph: false });
let ctx: AudioContext | null = null;
let gainNode: GainNode | null = null;
let limiter: DynamicsCompressorNode | null = null;
let building = false;
/** Fetches gains for the tracks from the current one through the lookahead. */
export async function ensureGains(): Promise<void> {
const ids = player.queue
.slice(Math.max(0, player.index), player.index + LOOKAHEAD)
.map((t) => t.id)
.filter((id) => !gains.has(id) && !inflight.has(id));
for (let i = 0; i < ids.length; i += BATCH) {
const batch = ids.slice(i, i + BATCH);
batch.forEach((id) => inflight.add(id));
try {
const res = await getReplayGain(batch);
for (const id of batch) gains.set(id, res.items[id] ?? null);
} catch {
// Left unset, so the next queue change asks again. Meanwhile the
// track plays as mastered.
} finally {
batch.forEach((id) => inflight.delete(id));
}
}
}
/** The gain for the queue item at `index`, in dB; 0 until its gain is known. */
export function gainDbAt(index: number): number {
const t = player.queue[index];
if (!t) return 0;
return gainDb(normalization.value, gains.get(t.id) ?? undefined, playingAsAlbum(player.queue, index));
}
/**
* Sets the element's volume to the user's volume times `fade`, leveled by
* `db`. Called from the layout's volume effect on every position update.
*/
export function applyGain(el: HTMLAudioElement, base: number, db: number): void {
const linear = dbToLinear(db);
if (!stage.graph || !ctx || !gainNode) {
el.volume = Math.min(1, base * Math.min(linear, 1));
return;
}
el.volume = base;
if (Math.abs(gainNode.gain.value - linear) > 1e-4) {
// A short ramp: a step in gain mid-signal is an audible click.
gainNode.gain.setTargetAtTime(linear, ctx.currentTime, 0.02);
}
configureLimiter();
}
export function hasGraph(): boolean {
return stage.graph;
}
// The limiter holds true peaks under -1 dBFS. In headroom mode the gain
// already keeps them there, so the compressor is set to pass audio through
// unchanged (ratio 1) rather than removed: the graph stays fixed.
function configureLimiter(): void {
if (!limiter || !ctx) return;
const on = normalization.value.boost === 'limiter';
const ratio = on ? 20 : 1;
if (limiter.ratio.value !== ratio) {
limiter.threshold.setValueAtTime(-1, ctx.currentTime);
limiter.knee.setValueAtTime(0, ctx.currentTime);
limiter.ratio.setValueAtTime(ratio, ctx.currentTime);
limiter.attack.setValueAtTime(0.003, ctx.currentTime);
limiter.release.setValueAtTime(0.25, ctx.currentTime);
}
}
/**
* Routes `el` through the gain graph, if that has not happened yet and a
* running AudioContext can be had. Browsers start a context suspended until
* the page has had a user gesture; the element is rerouted only after the
* context is confirmed running, so a refused context costs nothing and the
* next gesture tries again.
*/
export async function ensureGraph(el: HTMLAudioElement): Promise<void> {
if (stage.graph || building || isIOS()) return;
const AC: typeof AudioContext | undefined =
window.AudioContext ??
(window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!AC) return;
building = true;
const c = new AC();
try {
if (c.state !== 'running') await c.resume().catch(() => {});
if (c.state !== 'running') {
await c.close().catch(() => {});
return;
}
const source = c.createMediaElementSource(el);
const g = c.createGain();
const lim = c.createDynamicsCompressor();
source.connect(g).connect(lim).connect(c.destination);
ctx = c;
gainNode = g;
limiter = lim;
// Start at what the element was playing at, so the switch is silent.
g.gain.value = 1;
configureLimiter();
stage.graph = true;
} catch {
await c.close().catch(() => {});
} finally {
building = false;
}
}
// iOS suspends Web Audio when the screen locks, and a routed element goes
// silent with it, so iOS never gets the graph. It ignores element.volume
// too, so leveling there waits for the server's leveled stream (#5001).
function isIOS(): boolean {
const n = navigator;
return /iPad|iPhone|iPod/.test(n.userAgent) || (n.platform === 'MacIntel' && n.maxTouchPoints > 1);
}
/**
* Wakes the context if the browser suspended it (a backgrounded tab, an
* interruption). Routed audio plays only while the context runs.
*/
export function resumeGraph(): void {
if (ctx && ctx.state !== 'running') void ctx.resume().catch(() => {});
}
+41 -1
View File
@@ -28,6 +28,14 @@
import { applyMetaThemeColor } from '$lib/theme/applyMetaThemeColor.svelte'; import { applyMetaThemeColor } from '$lib/theme/applyMetaThemeColor.svelte';
import { audioLoader } from '$lib/player/audioLoader'; import { audioLoader } from '$lib/player/audioLoader';
import { attachStallRetry } from '$lib/player/stallRetry'; import { attachStallRetry } from '$lib/player/stallRetry';
import {
applyGain,
ensureGains,
ensureGraph,
gainDbAt,
resumeGraph
} from '$lib/player/gainStage.svelte';
import { untrack } from 'svelte';
let { children } = $props<{ children: import('svelte').Snippet }>(); let { children } = $props<{ children: import('svelte').Snippet }>();
let audioEl: HTMLAudioElement | undefined = $state(); let audioEl: HTMLAudioElement | undefined = $state();
@@ -95,8 +103,39 @@
// Per-position fade scalar: 1 normally, ramping in/out of the // Per-position fade scalar: 1 normally, ramping in/out of the
// crossfade window at track boundaries. Pure function of position // crossfade window at track boundaries. Pure function of position
// + duration + the operator's chosen crossfade duration. // + duration + the operator's chosen crossfade duration.
// Leveling (#4999) multiplies in last; the prefetch element is muted
// and never leveled.
const scalar = deriveFadeScalar(player.position, player.duration, player.crossfadeSec); const scalar = deriveFadeScalar(player.position, player.duration, player.crossfadeSec);
audioEl.volume = player.volume * scalar; applyGain(audioEl, player.volume * scalar, gainDbAt(player.index));
});
// Gains for the current track and those after it, fetched as the queue
// moves so each track's gain is in hand before it starts.
$effect(() => {
if (user.value === null) return;
player.queue;
player.index;
untrack(() => void ensureGains());
});
// A boost needs the Web Audio graph. It is built when the current or next
// track wants one, and from a user gesture if the browser refused a
// context without one.
const wantsBoost = $derived(gainDbAt(player.index) > 0 || gainDbAt(player.index + 1) > 0);
$effect(() => {
if (!audioEl || !wantsBoost) return;
const el = audioEl;
untrack(() => void ensureGraph(el));
const onGesture = () => {
resumeGraph();
void ensureGraph(el);
};
document.addEventListener('pointerdown', onGesture);
document.addEventListener('keydown', onGesture);
return () => {
document.removeEventListener('pointerdown', onGesture);
document.removeEventListener('keydown', onGesture);
};
}); });
$effect(() => { $effect(() => {
@@ -105,6 +144,7 @@
// emitted 'playing' yet, but we still need to call play() to get there. // emitted 'playing' yet, but we still need to call play() to get there.
const intent = player.state === 'playing' || player.state === 'loading'; const intent = player.state === 'playing' || player.state === 'loading';
if (intent) { if (intent) {
resumeGraph();
audioEl.play().catch(() => { /* interrupted / blocked — store gets the event */ }); audioEl.play().catch(() => { /* interrupted / blocked — store gets the event */ });
} else { } else {
audioEl.pause(); audioEl.pause();
+6 -1
View File
@@ -1,6 +1,7 @@
import type { LayoutLoad } from './$types'; import type { LayoutLoad } from './$types';
import { bootstrap, user } from '$lib/auth/store.svelte'; import { bootstrap, user } from '$lib/auth/store.svelte';
import { restoreQueue } from '$lib/player/store.svelte'; import { restoreQueue } from '$lib/player/store.svelte';
import { loadNormalization } from '$lib/stores/normalization.svelte';
export const ssr = false; // adapter-static fallback; we're SPA-only export const ssr = false; // adapter-static fallback; we're SPA-only
export const prerender = false; export const prerender = false;
@@ -8,6 +9,10 @@ export const prerender = false;
export const load: LayoutLoad = async () => { export const load: LayoutLoad = async () => {
await bootstrap(); await bootstrap();
const userId = user.value?.id; const userId = user.value?.id;
if (userId) restoreQueue(userId); if (userId) {
restoreQueue(userId);
// Not awaited: the cached preference levels playback until it lands.
void loadNormalization();
}
return {}; return {};
}; };