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