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:
Frank Stellmacher
2026-05-12 12:21:51 +02:00
committed by GitHub
parent b68bddd034
commit 81b161a418
3 changed files with 189 additions and 37 deletions
+137
View File
@@ -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();
});
});
+51
View File
@@ -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',
};
}
+1 -37
View File
@@ -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