From 38290bf8f921a82361bed610fedaa1a76f058b88 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 6 Oct 2026 18:21:41 -0400 Subject: [PATCH] feat(web): level playback by the user's normalization preference (M464 #4999) 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 --- web/src/lib/api/normalization.ts | 22 ++- web/src/lib/player/gain.test.ts | 86 +++++++++++ web/src/lib/player/gain.ts | 66 +++++++++ web/src/lib/player/gainStage.svelte.test.ts | 62 ++++++++ web/src/lib/player/gainStage.svelte.ts | 152 ++++++++++++++++++++ web/src/routes/+layout.svelte | 42 +++++- web/src/routes/+layout.ts | 7 +- 7 files changed, 434 insertions(+), 3 deletions(-) create mode 100644 web/src/lib/player/gain.test.ts create mode 100644 web/src/lib/player/gain.ts create mode 100644 web/src/lib/player/gainStage.svelte.test.ts create mode 100644 web/src/lib/player/gainStage.svelte.ts diff --git a/web/src/lib/api/normalization.ts b/web/src/lib/api/normalization.ts index c9ddad15..9c7db924 100644 --- a/web/src/lib/api/normalization.ts +++ b/web/src/lib/api/normalization.ts @@ -1,4 +1,4 @@ -import { api } from './client'; +import { api, apiFetch } from './client'; // Loudness normalization preference (M464 #4998). One per user, stored on // the server so the web player, the Android app and casts agree. @@ -40,3 +40,23 @@ export function getNormalization(): Promise { export function putNormalization(p: NormalizationPrefs): Promise { return api.put('/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 }> { + return apiFetch(`/api/tracks/replay-gain?ids=${ids.map(encodeURIComponent).join(',')}`, { + signal: AbortSignal.timeout(10_000) + }) as Promise<{ items: Record }>; +} diff --git a/web/src/lib/player/gain.test.ts b/web/src/lib/player/gain.test.ts new file mode 100644 index 00000000..0281907a --- /dev/null +++ b/web/src/lib/player/gain.test.ts @@ -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 => ({ + 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); +}); diff --git a/web/src/lib/player/gain.ts b/web/src/lib/player/gain.ts new file mode 100644 index 00000000..ecb0324f --- /dev/null +++ b/web/src/lib/player/gain.ts @@ -0,0 +1,66 @@ +// Loudness normalization math for the web player (M464 #4999). Pure, so the +// rules are tested without an