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