mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +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,
|
recordEnginePlayUrl,
|
||||||
shouldRebindPlaybackToHotCache,
|
shouldRebindPlaybackToHotCache,
|
||||||
} from './playbackUrlRouting';
|
} from './playbackUrlRouting';
|
||||||
|
import { deriveNormalizationSnapshot } from './normalizationSnapshot';
|
||||||
|
|
||||||
// Re-export the playback-progress public surface so existing call sites
|
// Re-export the playback-progress public surface so existing call sites
|
||||||
// (PlayerBar, FullscreenPlayer, WaveformSeek, LyricsPane, MobilePlayerView,
|
// (PlayerBar, FullscreenPlayer, WaveformSeek, LyricsPane, MobilePlayerView,
|
||||||
@@ -432,43 +433,6 @@ function emitNormalizationDebug(step: string, details?: Record<string, unknown>)
|
|||||||
}).catch(() => {});
|
}).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.
|
// Debounce timer for seek slider drags.
|
||||||
let seekDebounce: ReturnType<typeof setTimeout> | null = null;
|
let seekDebounce: ReturnType<typeof setTimeout> | null = null;
|
||||||
// Target time of the last seek — blocks stale Rust progress ticks until the
|
// Target time of the last seek — blocks stale Rust progress ticks until the
|
||||||
|
|||||||
Reference in New Issue
Block a user