diff --git a/src/store/loudnessBackfillWindow.test.ts b/src/store/loudnessBackfillWindow.test.ts new file mode 100644 index 00000000..0f6570e3 --- /dev/null +++ b/src/store/loudnessBackfillWindow.test.ts @@ -0,0 +1,91 @@ +/** + * Pure functions over a queue slice: the "is this id inside the prefetch + * window?" check and the "give me the window's id list" collector. Window + * = current track + next `LOUDNESS_BACKFILL_WINDOW_AHEAD` entries, with + * duplicates collapsed. + */ +import { describe, expect, it } from 'vitest'; +import type { Track } from './playerStore'; +import { + LOUDNESS_BACKFILL_WINDOW_AHEAD, + collectLoudnessBackfillWindowTrackIds, + isTrackInsideLoudnessBackfillWindow, +} from './loudnessBackfillWindow'; + +function track(id: string): Track { + return { id, title: id, artist: 'A', album: 'X', albumId: 'X', duration: 100 }; +} + +const big = Array.from({ length: 12 }, (_, i) => track(`t${i}`)); + +describe('LOUDNESS_BACKFILL_WINDOW_AHEAD', () => { + it('is the value the runtime expects', () => { + expect(LOUDNESS_BACKFILL_WINDOW_AHEAD).toBe(5); + }); +}); + +describe('isTrackInsideLoudnessBackfillWindow', () => { + it('matches the current track unconditionally', () => { + expect(isTrackInsideLoudnessBackfillWindow('t0', big, 0, big[0])).toBe(true); + }); + + it('matches an id inside the ahead window', () => { + // queueIndex 0, AHEAD 5 → indices 1..5 are inside, t3 must hit. + expect(isTrackInsideLoudnessBackfillWindow('t3', big, 0, big[0])).toBe(true); + }); + + it('returns false for an id beyond the ahead window', () => { + // From queueIndex 0, indices 1..5 inside → t6 (index 6) is outside. + expect(isTrackInsideLoudnessBackfillWindow('t6', big, 0, big[0])).toBe(false); + }); + + it('window slides with queueIndex', () => { + // queueIndex 4, AHEAD 5 → indices 5..9 are inside, t9 must hit, t10 must not. + expect(isTrackInsideLoudnessBackfillWindow('t9', big, 4, big[4])).toBe(true); + expect(isTrackInsideLoudnessBackfillWindow('t10', big, 4, big[4])).toBe(false); + }); + + it('returns false for empty queue', () => { + expect(isTrackInsideLoudnessBackfillWindow('t1', [], 0, null)).toBe(false); + }); + + it('returns false for empty trackId', () => { + expect(isTrackInsideLoudnessBackfillWindow('', big, 0, big[0])).toBe(false); + }); + + it('returns false when currentTrack is null and id is not in the queue window', () => { + expect(isTrackInsideLoudnessBackfillWindow('missing', big, 0, null)).toBe(false); + }); +}); + +describe('collectLoudnessBackfillWindowTrackIds', () => { + it('returns current + next 5 entries', () => { + const ids = collectLoudnessBackfillWindowTrackIds(big, 0, big[0]); + expect(ids).toEqual(['t0', 't1', 't2', 't3', 't4', 't5']); + }); + + it('clamps the window to the end of the queue', () => { + const ids = collectLoudnessBackfillWindowTrackIds(big, 9, big[9]); + // queueIndex 9, AHEAD 5 → indices 10..11 available → t9, t10, t11 + expect(ids).toEqual(['t9', 't10', 't11']); + }); + + it('omits the current track when null', () => { + const ids = collectLoudnessBackfillWindowTrackIds(big, 0, null); + expect(ids).toEqual(['t1', 't2', 't3', 't4', 't5']); + }); + + it('deduplicates when currentTrack is also in the ahead window', () => { + const queue = [track('a'), track('b'), track('a'), track('c')]; + const ids = collectLoudnessBackfillWindowTrackIds(queue, 0, queue[0]); + expect(ids).toEqual(['a', 'b', 'c']); + }); + + it('returns just the current track for an empty queue', () => { + expect(collectLoudnessBackfillWindowTrackIds([], 0, track('only'))).toEqual(['only']); + }); + + it('returns an empty list when nothing is playing and the queue is empty', () => { + expect(collectLoudnessBackfillWindowTrackIds([], 0, null)).toEqual([]); + }); +}); diff --git a/src/store/loudnessBackfillWindow.ts b/src/store/loudnessBackfillWindow.ts new file mode 100644 index 00000000..db431de7 --- /dev/null +++ b/src/store/loudnessBackfillWindow.ts @@ -0,0 +1,48 @@ +import type { Track } from './playerStore'; + +/** + * After a bulk enqueue (queue replace, append-many, lucky-mix) the runtime + * warms the loudness cache for the current track + the next N entries so + * the engine's `audio_chain_preload` sees a real cached gain instead of + * the startup trim. These helpers compute that window — both as a + * "does this id sit inside it?" predicate and as the explicit id list + * the prefetch loop iterates over. + * + * Pure functions of the state slice — no store imports, no side effects. + * The caller passes the queue + index + current track so the test surface + * stays trivial and there's no top-level coupling back to playerStore. + */ +export const LOUDNESS_BACKFILL_WINDOW_AHEAD = 5; + +export function isTrackInsideLoudnessBackfillWindow( + trackId: string, + queue: Track[], + queueIndex: number, + currentTrack: Track | null, +): boolean { + if (!trackId) return false; + if (currentTrack?.id === trackId) return true; + if (queue.length === 0) return false; + const start = Math.max(0, queueIndex + 1); + const end = Math.min(queue.length, start + LOUDNESS_BACKFILL_WINDOW_AHEAD); + for (let i = start; i < end; i++) { + if (queue[i]?.id === trackId) return true; + } + return false; +} + +export function collectLoudnessBackfillWindowTrackIds( + queue: Track[], + queueIndex: number, + currentTrack: Track | null, +): string[] { + const ids = new Set(); + if (currentTrack?.id) ids.add(currentTrack.id); + const start = Math.max(0, queueIndex + 1); + const end = Math.min(queue.length, start + LOUDNESS_BACKFILL_WINDOW_AHEAD); + for (let i = start; i < end; i++) { + const tid = queue[i]?.id; + if (tid) ids.add(tid); + } + return Array.from(ids); +} diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index 13f12c35..f9a409e6 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -84,6 +84,11 @@ import { resetBackfillAttempts, resetLoudnessBackfillStateForTrackId, } from './loudnessBackfillState'; +import { + LOUDNESS_BACKFILL_WINDOW_AHEAD, + collectLoudnessBackfillWindowTrackIds, + isTrackInsideLoudnessBackfillWindow, +} from './loudnessBackfillWindow'; // Re-export the playback-progress public surface so existing call sites // (PlayerBar, FullscreenPlayer, WaveformSeek, LyricsPane, MobilePlayerView, @@ -721,10 +726,11 @@ async function runRefreshLoudnessForTrack(trackId: string, syncEngine: boolean): if (auth.normalizationEngine === 'loudness' && !isBackfillInFlight(trackId) && attempts < MAX_BACKFILL_ATTEMPTS_PER_TRACK) { - if (!isTrackInsideLoudnessBackfillWindow(trackId)) { + const live = usePlayerStore.getState(); + if (!isTrackInsideLoudnessBackfillWindow(trackId, live.queue, live.queueIndex, live.currentTrack)) { emitNormalizationDebug('backfill:skipped-outside-window', { trackId, - queueIndex: usePlayerStore.getState().queueIndex, + queueIndex: live.queueIndex, aheadWindow: LOUDNESS_BACKFILL_WINDOW_AHEAD, }); return; @@ -774,35 +780,6 @@ async function runRefreshLoudnessForTrack(trackId: string, syncEngine: boolean): } } -/** After bulk enqueue, warm loudness cache so gapless `audio_chain_preload` sees real gain, not only startup trim. */ -const LOUDNESS_BACKFILL_WINDOW_AHEAD = 5; - -function isTrackInsideLoudnessBackfillWindow(trackId: string): boolean { - if (!trackId) return false; - const state = usePlayerStore.getState(); - const currentId = state.currentTrack?.id; - if (currentId === trackId) return true; - if (state.queue.length === 0) return false; - const start = Math.max(0, state.queueIndex + 1); - const end = Math.min(state.queue.length, start + LOUDNESS_BACKFILL_WINDOW_AHEAD); - for (let i = start; i < end; i++) { - if (state.queue[i]?.id === trackId) return true; - } - return false; -} - -function collectLoudnessBackfillWindowTrackIds(queue: Track[], queueIndex: number, currentTrack: Track | null): string[] { - const ids = new Set(); - if (currentTrack?.id) ids.add(currentTrack.id); - const start = Math.max(0, queueIndex + 1); - const end = Math.min(queue.length, start + LOUDNESS_BACKFILL_WINDOW_AHEAD); - for (let i = start; i < end; i++) { - const tid = queue[i]?.id; - if (tid) ids.add(tid); - } - return Array.from(ids); -} - function prefetchLoudnessForEnqueuedTracks( mergedQueue: Track[], queueIndex: number,