diff --git a/src/store/normalizationSnapshot.test.ts b/src/store/normalizationSnapshot.test.ts new file mode 100644 index 00000000..6128b5a0 --- /dev/null +++ b/src/store/normalizationSnapshot.test.ts @@ -0,0 +1,137 @@ +/** + * Derived normalization snapshot — three engine branches and a couple of + * replaygain corner cases (no tag → fallback, neighbour-track context for + * album-mode resolution). useAuthStore is mocked through a hoisted state + * object so each test can flip flags without rebuilding the store. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Track } from './playerStore'; + +const { authState } = vi.hoisted(() => ({ + authState: { + normalizationEngine: 'off' as 'off' | 'replaygain' | 'loudness', + loudnessTargetLufs: -14, + replayGainEnabled: false, + replayGainMode: 'track' as 'track' | 'album', + replayGainPreGainDb: 0, + replayGainFallbackDb: -6, + }, +})); + +vi.mock('./authStore', () => ({ + useAuthStore: { getState: () => authState }, +})); + +vi.mock('../utils/resolveReplayGainDb', () => ({ + resolveReplayGainDb: vi.fn( + (track: Track) => (track as Track & { _testGain?: number | null })._testGain ?? null, + ), +})); + +import { deriveNormalizationSnapshot } from './normalizationSnapshot'; + +function track(id: string, gain: number | null = null): Track { + return { + id, + title: id, + artist: 'A', + album: 'X', + albumId: 'X', + duration: 100, + ...(gain !== null ? { _testGain: gain } : {}), + } as Track; +} + +beforeEach(() => { + authState.normalizationEngine = 'off'; + authState.loudnessTargetLufs = -14; + authState.replayGainEnabled = false; + authState.replayGainMode = 'track'; + authState.replayGainPreGainDb = 0; + authState.replayGainFallbackDb = -6; +}); + +describe("engine='off'", () => { + it('returns a fully-null snapshot', () => { + expect(deriveNormalizationSnapshot(track('a'), [track('a')], 0)).toEqual({ + normalizationNowDb: null, + normalizationTargetLufs: null, + normalizationEngineLive: 'off', + }); + }); +}); + +describe("engine='loudness'", () => { + it('returns the configured target LUFS and clears nowDb', () => { + authState.normalizationEngine = 'loudness'; + authState.loudnessTargetLufs = -10; + expect(deriveNormalizationSnapshot(track('a'), [track('a')], 0)).toEqual({ + normalizationNowDb: null, + normalizationTargetLufs: -10, + normalizationEngineLive: 'loudness', + }); + }); +}); + +describe("engine='replaygain'", () => { + it("falls through to 'off' when replayGainEnabled is false", () => { + authState.normalizationEngine = 'replaygain'; + authState.replayGainEnabled = false; + expect(deriveNormalizationSnapshot(track('a'), [track('a')], 0).normalizationEngineLive).toBe('off'); + }); + + it('uses the resolved gain + pre-gain when a tag is present', () => { + authState.normalizationEngine = 'replaygain'; + authState.replayGainEnabled = true; + authState.replayGainPreGainDb = 2; + const snap = deriveNormalizationSnapshot(track('a', -8), [track('a', -8)], 0); + expect(snap.normalizationNowDb).toBe(-6); // -8 + 2 + expect(snap.normalizationEngineLive).toBe('replaygain'); + expect(snap.normalizationTargetLufs).toBeNull(); + }); + + it('uses the fallback dB when no tag is resolvable', () => { + authState.normalizationEngine = 'replaygain'; + authState.replayGainEnabled = true; + authState.replayGainFallbackDb = -3; + const snap = deriveNormalizationSnapshot(track('a'), [track('a')], 0); + expect(snap.normalizationNowDb).toBe(-3); + expect(snap.normalizationEngineLive).toBe('replaygain'); + }); + + it('passes neighbour-track context for album-mode resolution', async () => { + const { resolveReplayGainDb } = await import('../utils/resolveReplayGainDb'); + const spy = vi.mocked(resolveReplayGainDb); + spy.mockClear(); + authState.normalizationEngine = 'replaygain'; + authState.replayGainEnabled = true; + authState.replayGainMode = 'album'; + const queue = [track('prev'), track('current'), track('next')]; + deriveNormalizationSnapshot(queue[1], queue, 1); + expect(spy).toHaveBeenCalledWith(queue[1], queue[0], queue[2], true, 'album'); + }); + + it('passes null for prev when queueIndex is 0', async () => { + const { resolveReplayGainDb } = await import('../utils/resolveReplayGainDb'); + const spy = vi.mocked(resolveReplayGainDb); + spy.mockClear(); + authState.normalizationEngine = 'replaygain'; + authState.replayGainEnabled = true; + const queue = [track('a'), track('b')]; + deriveNormalizationSnapshot(queue[0], queue, 0); + expect(spy.mock.calls[0][1]).toBeNull(); + expect(spy.mock.calls[0][2]).toEqual(queue[1]); + }); + + it('passes null for next when queueIndex is the last slot', async () => { + const { resolveReplayGainDb } = await import('../utils/resolveReplayGainDb'); + const spy = vi.mocked(resolveReplayGainDb); + spy.mockClear(); + authState.normalizationEngine = 'replaygain'; + authState.replayGainEnabled = true; + const queue = [track('a'), track('b')]; + deriveNormalizationSnapshot(queue[1], queue, 1); + expect(spy.mock.calls[0][1]).toEqual(queue[0]); + expect(spy.mock.calls[0][2]).toBeNull(); + }); +}); diff --git a/src/store/normalizationSnapshot.ts b/src/store/normalizationSnapshot.ts new file mode 100644 index 00000000..14ca3762 --- /dev/null +++ b/src/store/normalizationSnapshot.ts @@ -0,0 +1,51 @@ +import { useAuthStore } from './authStore'; +import { resolveReplayGainDb } from '../utils/resolveReplayGainDb'; +import type { PlayerState, Track } from './playerStore'; + +/** + * Compute the normalization fields that should land in the next state commit + * when the runtime switches tracks or rewrites the queue. Three branches: + * + * - **loudness** — clear the visible dB until the engine pushes a real + * `audio:normalization-state` event back; surface the user's target LUFS. + * - **replaygain (enabled)** — derive the dB from track/album tags via + * `resolveReplayGainDb`, add the pre-gain, fall back to the configured + * fallback when no tag is available. + * - **off** (or replaygain disabled) — everything null, engine `'off'`. + */ +export function deriveNormalizationSnapshot( + track: Track, + queue: Track[], + queueIndex: number, +): Pick< + PlayerState, + 'normalizationNowDb' | 'normalizationTargetLufs' | 'normalizationEngineLive' +> { + const auth = useAuthStore.getState(); + const engine = auth.normalizationEngine; + if (engine === 'loudness') { + const target = auth.loudnessTargetLufs; + return { + // Clears stale UI until `audio:normalization-state` / refresh catches up. + normalizationNowDb: null, + normalizationTargetLufs: target, + normalizationEngineLive: 'loudness', + }; + } + if (engine === 'replaygain' && auth.replayGainEnabled) { + const prev = queueIndex > 0 ? queue[queueIndex - 1] : null; + const next = queueIndex + 1 < queue.length ? queue[queueIndex + 1] : null; + const resolved = resolveReplayGainDb(track, prev, next, true, auth.replayGainMode); + const nowDb = resolved != null ? (resolved + auth.replayGainPreGainDb) : auth.replayGainFallbackDb; + return { + normalizationNowDb: nowDb, + normalizationTargetLufs: null, + normalizationEngineLive: 'replaygain', + }; + } + return { + normalizationNowDb: null, + normalizationTargetLufs: null, + normalizationEngineLive: 'off', + }; +} diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index 77a9f3af..ce034d52 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -44,6 +44,7 @@ import { recordEnginePlayUrl, shouldRebindPlaybackToHotCache, } from './playbackUrlRouting'; +import { deriveNormalizationSnapshot } from './normalizationSnapshot'; // Re-export the playback-progress public surface so existing call sites // (PlayerBar, FullscreenPlayer, WaveformSeek, LyricsPane, MobilePlayerView, @@ -432,43 +433,6 @@ function emitNormalizationDebug(step: string, details?: Record) }).catch(() => {}); } -function deriveNormalizationSnapshot( - track: Track, - queue: Track[], - queueIndex: number, -): Pick< - PlayerState, - 'normalizationNowDb' | 'normalizationTargetLufs' | 'normalizationEngineLive' -> { - const auth = useAuthStore.getState(); - const engine = auth.normalizationEngine; - if (engine === 'loudness') { - const target = auth.loudnessTargetLufs; - return { - // Clears stale UI until `audio:normalization-state` / refresh catches up. - normalizationNowDb: null, - normalizationTargetLufs: target, - normalizationEngineLive: 'loudness', - }; - } - if (engine === 'replaygain' && auth.replayGainEnabled) { - const prev = queueIndex > 0 ? queue[queueIndex - 1] : null; - const next = queueIndex + 1 < queue.length ? queue[queueIndex + 1] : null; - const resolved = resolveReplayGainDb(track, prev, next, true, auth.replayGainMode); - const nowDb = resolved != null ? (resolved + auth.replayGainPreGainDb) : auth.replayGainFallbackDb; - return { - normalizationNowDb: nowDb, - normalizationTargetLufs: null, - normalizationEngineLive: 'replaygain', - }; - } - return { - normalizationNowDb: null, - normalizationTargetLufs: null, - normalizationEngineLive: 'off', - }; -} - // Debounce timer for seek slider drags. let seekDebounce: ReturnType | null = null; // Target time of the last seek — blocks stale Rust progress ticks until the