From c466e6c3179ddd42e262a6f2db0ccf0d6ad72d2c Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 1 Jun 2026 14:22:30 -0400 Subject: [PATCH] feat(web): crossfade between tracks (Tier C11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator-tunable 0-12s crossfade in Settings → Playback. Default 0 (off). Most albums sound best at 0 — gapless masters, classical, and live recordings all suffer noticeable crossfades. Implementation: pure-function position-derived volume scalar. `deriveFadeScalar(position, duration, crossfadeSec)` ramps from 0 to 1 over the leading X seconds, 1 to 0 over the trailing X seconds, and stays at 1 in between. Tracks shorter than 2X don't fade. The layout's audio-volume effect now multiplies player.volume by the fade scalar — no timers, no AudioContext, no element swapping. Re-renders at the ~4Hz `timeupdate` cadence give 16 discrete steps over a 4s fade, audibly close to smooth. Smoothing to per-frame ramps via rAF is a follow-up if needed. Setting persists to localStorage as `minstrel.crossfade` (matches the existing volume-storage convention). Tests cover the pure derivation (off, too-short track, fade-in, fade-out) + clamp/persist on setCrossfade + invalid-input recovery on readStoredCrossfade. --- web/src/lib/player/store.svelte.ts | 42 ++++++++++++++++++++++ web/src/lib/player/store.test.ts | 52 ++++++++++++++++++++++++++++ web/src/routes/+layout.svelte | 10 ++++-- web/src/routes/settings/+page.svelte | 29 ++++++++++++++++ 4 files changed, 131 insertions(+), 2 deletions(-) diff --git a/web/src/lib/player/store.svelte.ts b/web/src/lib/player/store.svelte.ts index f720cfc3..2a7577d3 100644 --- a/web/src/lib/player/store.svelte.ts +++ b/web/src/lib/player/store.svelte.ts @@ -10,6 +10,10 @@ export type PlayerState = 'idle' | 'loading' | 'playing' | 'paused' | 'error'; export type RepeatMode = 'off' | 'all' | 'one'; const VOLUME_KEY = 'minstrel.volume'; +const CROSSFADE_KEY = 'minstrel.crossfade'; + +const CROSSFADE_MIN = 0; +const CROSSFADE_MAX = 12; export function readStoredVolume(): number { const raw = storage.read(VOLUME_KEY); @@ -19,12 +23,43 @@ export function readStoredVolume(): number { return 1; } +export function readStoredCrossfade(): number { + const raw = storage.read(CROSSFADE_KEY); + if (raw == null) return 0; + const n = Number(raw); + if (Number.isFinite(n) && n >= CROSSFADE_MIN && n <= CROSSFADE_MAX) { + return Math.round(n); + } + return 0; +} + +// Pure-function: maps (position, duration, crossfadeSec) → volume scalar +// in [0,1]. Outside the crossfade windows it's 1. In the leading X +// seconds: position/X (fade-in). In the trailing X seconds: +// (duration-position)/X (fade-out). Tracks shorter than 2X don't fade. +export function deriveFadeScalar( + position: number, + duration: number, + crossfadeSec: number +): number { + if (crossfadeSec <= 0 || duration <= 0) return 1; + if (duration < crossfadeSec * 2) return 1; + if (position < crossfadeSec) { + return Math.max(0, Math.min(1, position / crossfadeSec)); + } + if (duration - position < crossfadeSec) { + return Math.max(0, Math.min(1, (duration - position) / crossfadeSec)); + } + return 1; +} + let _queue = $state([]); let _index = $state(0); let _state = $state('idle'); let _position = $state(0); let _duration = $state(0); let _volume = $state(readStoredVolume()); +let _crossfadeSec = $state(readStoredCrossfade()); let _shuffle = $state(false); let _repeat = $state('off'); let _error = $state(null); @@ -59,6 +94,7 @@ export const player = { get position() { return _position; }, get duration() { return _duration; }, get volume() { return _volume; }, + get crossfadeSec() { return _crossfadeSec; }, get shuffle() { return _shuffle; }, get repeat() { return _repeat; }, get error() { return _error; }, @@ -166,6 +202,12 @@ export function setVolume(v: number): void { storage.write(VOLUME_KEY, String(clamped)); } +export function setCrossfade(sec: number): void { + const clamped = Math.max(CROSSFADE_MIN, Math.min(CROSSFADE_MAX, Math.round(sec))); + _crossfadeSec = clamped; + storage.write(CROSSFADE_KEY, String(clamped)); +} + // Last non-zero volume captured so the M shortcut can toggle to silent // then restore. Set lazily on the first toggle and on any setVolume // that's non-zero. Default 0.5 keeps unmute usable even if the user diff --git a/web/src/lib/player/store.test.ts b/web/src/lib/player/store.test.ts index bc83a7ea..f4c144fe 100644 --- a/web/src/lib/player/store.test.ts +++ b/web/src/lib/player/store.test.ts @@ -591,3 +591,55 @@ describe('queue mutations', () => { expect(player.queue.map((x) => x.id)).toEqual(['a']); }); }); + +describe('crossfade', () => { + test('deriveFadeScalar returns 1 when crossfadeSec is 0', async () => { + const { deriveFadeScalar } = await import('./store.svelte'); + expect(deriveFadeScalar(50, 200, 0)).toBe(1); + expect(deriveFadeScalar(0, 200, 0)).toBe(1); + expect(deriveFadeScalar(200, 200, 0)).toBe(1); + }); + + test('deriveFadeScalar returns 1 when track is too short to fade twice', async () => { + const { deriveFadeScalar } = await import('./store.svelte'); + // 6s track with 4s crossfade: 6 < 4*2, so no fade. + expect(deriveFadeScalar(0, 6, 4)).toBe(1); + expect(deriveFadeScalar(3, 6, 4)).toBe(1); + }); + + test('deriveFadeScalar ramps in over the leading X seconds', async () => { + const { deriveFadeScalar } = await import('./store.svelte'); + // 60s track, 4s crossfade. + expect(deriveFadeScalar(0, 60, 4)).toBe(0); + expect(deriveFadeScalar(2, 60, 4)).toBe(0.5); + expect(deriveFadeScalar(4, 60, 4)).toBe(1); + }); + + test('deriveFadeScalar ramps out over the trailing X seconds', async () => { + const { deriveFadeScalar } = await import('./store.svelte'); + expect(deriveFadeScalar(56, 60, 4)).toBe(1); + expect(deriveFadeScalar(58, 60, 4)).toBe(0.5); + expect(deriveFadeScalar(60, 60, 4)).toBe(0); + }); + + test('setCrossfade clamps to [0, 12] and persists', async () => { + const { setCrossfade, player } = await import('./store.svelte'); + setCrossfade(4); + expect(player.crossfadeSec).toBe(4); + expect(localStorage.getItem('minstrel.crossfade')).toBe('4'); + setCrossfade(99); + expect(player.crossfadeSec).toBe(12); + setCrossfade(-3); + expect(player.crossfadeSec).toBe(0); + }); + + test('readStoredCrossfade survives an invalid stored value', async () => { + const { readStoredCrossfade } = await import('./store.svelte'); + localStorage.setItem('minstrel.crossfade', 'banana'); + expect(readStoredCrossfade()).toBe(0); + localStorage.setItem('minstrel.crossfade', '500'); + expect(readStoredCrossfade()).toBe(0); + localStorage.setItem('minstrel.crossfade', '7'); + expect(readStoredCrossfade()).toBe(7); + }); +}); diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index 505e5946..e6eb4821 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -18,7 +18,8 @@ reportStateFromAudio, player, closeQueueDrawer, - consumePendingRestorePosition + consumePendingRestorePosition, + deriveFadeScalar } from '$lib/player/store.svelte'; import { useMediaSession } from '$lib/player/mediaSession.svelte'; import { useEventsDispatcher } from '$lib/player/events.svelte'; @@ -89,7 +90,12 @@ }); $effect(() => { - if (audioEl) audioEl.volume = player.volume; + if (!audioEl) return; + // 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. + const scalar = deriveFadeScalar(player.position, player.duration, player.crossfadeSec); + audioEl.volume = player.volume * scalar; }); $effect(() => { diff --git a/web/src/routes/settings/+page.svelte b/web/src/routes/settings/+page.svelte index 4dbabce3..453b5245 100644 --- a/web/src/routes/settings/+page.svelte +++ b/web/src/routes/settings/+page.svelte @@ -9,6 +9,7 @@ type LBStatus } from '$lib/api/listenbrainz'; import { theme, setTheme, type ThemePreference } from '$lib/stores/theme.svelte'; + import { player, setCrossfade } from '$lib/player/store.svelte'; import { updateProfile, changePassword, @@ -175,6 +176,34 @@

+
+

Playback

+ +
+
+ + + {player.crossfadeSec === 0 ? 'Off' : `${player.crossfadeSec}s`} + +
+ setCrossfade(Number((e.currentTarget as HTMLInputElement).value))} + aria-label="Crossfade duration in seconds" + class="w-full accent-accent" + /> +

+ Fades the end of one track into the start of the next. + 0 = off · most albums sound best at 0. +

+
+
+

ListenBrainz