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
+41 -1
View File
@@ -28,6 +28,14 @@
import { applyMetaThemeColor } from '$lib/theme/applyMetaThemeColor.svelte';
import { audioLoader } from '$lib/player/audioLoader';
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 audioEl: HTMLAudioElement | undefined = $state();
@@ -95,8 +103,39 @@
// Per-position fade scalar: 1 normally, ramping in/out of the
// crossfade window at track boundaries. Pure function of position
// + 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);
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(() => {
@@ -105,6 +144,7 @@
// emitted 'playing' yet, but we still need to call play() to get there.
const intent = player.state === 'playing' || player.state === 'loading';
if (intent) {
resumeGraph();
audioEl.play().catch(() => { /* interrupted / blocked — store gets the event */ });
} else {
audioEl.pause();