mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
refactor(player): E.6 — extract deriveNormalizationSnapshot into module (#569)
`deriveNormalizationSnapshot` — the loudness / replaygain / off branch that the runtime uses to compute the normalization fields on every track switch and queue rewrite — moves into src/store/normalizationSnapshot.ts. File-private, four internal call sites updated to import from the new module. Adds 8 focused tests pinning each branch (off, loudness with target LUFS, replaygain enabled with tag + pre-gain, replaygain enabled with fallback) plus neighbour-track context for album-mode resolution. playerStore 3470 → 3434 LOC.
This commit is contained in:
committed by
GitHub
parent
b68bddd034
commit
81b161a418
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
@@ -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<string, unknown>)
|
||||
}).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<typeof setTimeout> | null = null;
|
||||
// Target time of the last seek — blocks stale Rust progress ticks until the
|
||||
|
||||
Reference in New Issue
Block a user