mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
refactor(player): E.10 — extract normalization-IPC deduplicators (#573)
`invokeAudioSetNormalizationDeduped` (450 ms window for `audio_set_normalization`) and `invokeAudioUpdateReplayGainDeduped` (250 ms window for `audio_update_replay_gain`, with LUFS-target / pre-trim implicitly contributing to the dedupe key) move into `src/store/normalizationIpcDedupe.ts` along with their four "last-invoked" mutables. File-private throughout; three internal call sites become plain imports. 11 focused tests cover the window-boundary behaviour, the payload-field sensitivity, the engine-mode key contribution (re-fires when LUFS target changes even with identical gain), and the null / NaN serialization. playerStore 3369 → 3302 LOC.
This commit is contained in:
committed by
GitHub
parent
89bf7e2364
commit
86b13dd4d0
@@ -0,0 +1,130 @@
|
|||||||
|
/**
|
||||||
|
* IPC dedupers — each helper collapses repeat calls within a time-bounded
|
||||||
|
* window. The interesting behaviour is the engine-mode contribution to the
|
||||||
|
* replay-gain dedupe key (so changing the LUFS target re-fires even when
|
||||||
|
* the cached dB stays the same) and the null-aware number formatter.
|
||||||
|
*/
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const { authState, invokeMock } = vi.hoisted(() => ({
|
||||||
|
authState: {
|
||||||
|
normalizationEngine: 'off' as 'off' | 'replaygain' | 'loudness',
|
||||||
|
loudnessTargetLufs: -14,
|
||||||
|
loudnessPreAnalysisAttenuationDb: 0,
|
||||||
|
},
|
||||||
|
invokeMock: vi.fn(async (_cmd: string, _args?: Record<string, unknown>) => undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@tauri-apps/api/core', () => ({ invoke: invokeMock }));
|
||||||
|
vi.mock('./authStore', () => ({ useAuthStore: { getState: () => authState } }));
|
||||||
|
vi.mock('../utils/loudnessPreAnalysisSlider', () => ({
|
||||||
|
effectiveLoudnessPreAnalysisAttenuationDb: (attenuation: number) => attenuation,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
_resetNormalizationIpcDedupeForTest,
|
||||||
|
invokeAudioSetNormalizationDeduped,
|
||||||
|
invokeAudioUpdateReplayGainDeduped,
|
||||||
|
} from './normalizationIpcDedupe';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date('2026-05-12T12:00:00Z'));
|
||||||
|
invokeMock.mockClear();
|
||||||
|
authState.normalizationEngine = 'off';
|
||||||
|
authState.loudnessTargetLufs = -14;
|
||||||
|
authState.loudnessPreAnalysisAttenuationDb = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
_resetNormalizationIpcDedupeForTest();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('invokeAudioSetNormalizationDeduped', () => {
|
||||||
|
const payload = { engine: 'loudness', targetLufs: -14, preAnalysisAttenuationDb: 0 };
|
||||||
|
|
||||||
|
it('passes the first call through', () => {
|
||||||
|
invokeAudioSetNormalizationDeduped(payload);
|
||||||
|
expect(invokeMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(invokeMock).toHaveBeenCalledWith('audio_set_normalization', payload);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips a repeat with the same payload inside the 450 ms window', () => {
|
||||||
|
invokeAudioSetNormalizationDeduped(payload);
|
||||||
|
vi.advanceTimersByTime(449);
|
||||||
|
invokeAudioSetNormalizationDeduped(payload);
|
||||||
|
expect(invokeMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fires again once the 450 ms window elapses', () => {
|
||||||
|
invokeAudioSetNormalizationDeduped(payload);
|
||||||
|
vi.advanceTimersByTime(450);
|
||||||
|
invokeAudioSetNormalizationDeduped(payload);
|
||||||
|
expect(invokeMock).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fires when any payload field changes within the window', () => {
|
||||||
|
invokeAudioSetNormalizationDeduped(payload);
|
||||||
|
invokeAudioSetNormalizationDeduped({ ...payload, targetLufs: -10 });
|
||||||
|
invokeAudioSetNormalizationDeduped({ ...payload, engine: 'replaygain' });
|
||||||
|
invokeAudioSetNormalizationDeduped({ ...payload, preAnalysisAttenuationDb: -2 });
|
||||||
|
expect(invokeMock).toHaveBeenCalledTimes(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('invokeAudioUpdateReplayGainDeduped', () => {
|
||||||
|
const payload = {
|
||||||
|
volume: 0.8,
|
||||||
|
replayGainDb: -6,
|
||||||
|
replayGainPeak: 0.9,
|
||||||
|
loudnessGainDb: -3,
|
||||||
|
preGainDb: 0,
|
||||||
|
fallbackDb: -6,
|
||||||
|
};
|
||||||
|
|
||||||
|
it('passes the first call through', () => {
|
||||||
|
invokeAudioUpdateReplayGainDeduped(payload);
|
||||||
|
expect(invokeMock).toHaveBeenCalledWith('audio_update_replay_gain', payload);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips a repeat with the same payload inside the 250 ms window', () => {
|
||||||
|
invokeAudioUpdateReplayGainDeduped(payload);
|
||||||
|
vi.advanceTimersByTime(249);
|
||||||
|
invokeAudioUpdateReplayGainDeduped(payload);
|
||||||
|
expect(invokeMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fires again once the 250 ms window elapses', () => {
|
||||||
|
invokeAudioUpdateReplayGainDeduped(payload);
|
||||||
|
vi.advanceTimersByTime(250);
|
||||||
|
invokeAudioUpdateReplayGainDeduped(payload);
|
||||||
|
expect(invokeMock).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-fires when the LUFS target changes even if the dB payload stays the same', () => {
|
||||||
|
authState.normalizationEngine = 'loudness';
|
||||||
|
authState.loudnessTargetLufs = -14;
|
||||||
|
invokeAudioUpdateReplayGainDeduped(payload);
|
||||||
|
authState.loudnessTargetLufs = -10;
|
||||||
|
invokeAudioUpdateReplayGainDeduped(payload);
|
||||||
|
expect(invokeMock).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats null and non-finite gain values as the dedupe-string "null"', () => {
|
||||||
|
invokeAudioUpdateReplayGainDeduped({ ...payload, replayGainDb: null });
|
||||||
|
invokeAudioUpdateReplayGainDeduped({ ...payload, replayGainDb: Number.NaN });
|
||||||
|
// Same dedupe-key (both serialize to "null") + same window → second call dropped.
|
||||||
|
expect(invokeMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('_resetNormalizationIpcDedupeForTest', () => {
|
||||||
|
it('lets the next call through again after a reset', () => {
|
||||||
|
const payload = { engine: 'off', targetLufs: -14, preAnalysisAttenuationDb: 0 };
|
||||||
|
invokeAudioSetNormalizationDeduped(payload);
|
||||||
|
_resetNormalizationIpcDedupeForTest();
|
||||||
|
invokeAudioSetNormalizationDeduped(payload);
|
||||||
|
expect(invokeMock).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { useAuthStore } from './authStore';
|
||||||
|
import { effectiveLoudnessPreAnalysisAttenuationDb } from '../utils/loudnessPreAnalysisSlider';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two IPC entry points to the Rust normalization pipeline that get hammered
|
||||||
|
* by analysis ticks, queue rewrites, and React-StrictMode double mounts —
|
||||||
|
* each carries its own time-bounded de-duplicator so the same payload sent
|
||||||
|
* within a short window collapses into a single `invoke`.
|
||||||
|
*
|
||||||
|
* - `invokeAudioSetNormalizationDeduped` — `audio_set_normalization`
|
||||||
|
* (engine + target + pre-attenuation). 450 ms window.
|
||||||
|
* - `invokeAudioUpdateReplayGainDeduped` — `audio_update_replay_gain`
|
||||||
|
* (per-track gain + peak). 250 ms window. The dedupe key picks up the
|
||||||
|
* LUFS target / pre-trim implicitly so Rust still recomputes when the
|
||||||
|
* user changes the target even if JS happens to forward the same dB.
|
||||||
|
*/
|
||||||
|
|
||||||
|
let lastNormAudioInvokeKey = '';
|
||||||
|
let lastNormAudioInvokeAtMs = 0;
|
||||||
|
|
||||||
|
const NORMALIZATION_DEDUPE_WINDOW_MS = 450;
|
||||||
|
|
||||||
|
export function invokeAudioSetNormalizationDeduped(payload: {
|
||||||
|
engine: string;
|
||||||
|
targetLufs: number;
|
||||||
|
preAnalysisAttenuationDb: number;
|
||||||
|
}): void {
|
||||||
|
const key = `${payload.engine}|${payload.targetLufs}|${payload.preAnalysisAttenuationDb}`;
|
||||||
|
const now = Date.now();
|
||||||
|
if (key === lastNormAudioInvokeKey && now - lastNormAudioInvokeAtMs < NORMALIZATION_DEDUPE_WINDOW_MS) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastNormAudioInvokeKey = key;
|
||||||
|
lastNormAudioInvokeAtMs = now;
|
||||||
|
void invoke('audio_set_normalization', payload).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastRgInvokeKey = '';
|
||||||
|
let lastRgInvokeAtMs = 0;
|
||||||
|
|
||||||
|
const REPLAY_GAIN_DEDUPE_WINDOW_MS = 250;
|
||||||
|
|
||||||
|
export function invokeAudioUpdateReplayGainDeduped(payload: {
|
||||||
|
volume: number;
|
||||||
|
replayGainDb: number | null;
|
||||||
|
replayGainPeak: number | null;
|
||||||
|
loudnessGainDb: number | null;
|
||||||
|
preGainDb: number;
|
||||||
|
fallbackDb: number;
|
||||||
|
}): void {
|
||||||
|
const auth = useAuthStore.getState();
|
||||||
|
/** Must vary when LUFS target / pre-trim changes: Rust recomputes in `audio_update_replay_gain` even if JS still sends the same cached dB. */
|
||||||
|
const preEff =
|
||||||
|
auth.normalizationEngine === 'loudness'
|
||||||
|
? effectiveLoudnessPreAnalysisAttenuationDb(
|
||||||
|
auth.loudnessPreAnalysisAttenuationDb,
|
||||||
|
auth.loudnessTargetLufs,
|
||||||
|
)
|
||||||
|
: auth.loudnessPreAnalysisAttenuationDb;
|
||||||
|
const normDedupeKey =
|
||||||
|
auth.normalizationEngine === 'loudness'
|
||||||
|
? `loudness|tgt=${auth.loudnessTargetLufs}|pre=${preEff.toFixed(2)}`
|
||||||
|
: auth.normalizationEngine === 'replaygain'
|
||||||
|
? 'replaygain'
|
||||||
|
: 'off';
|
||||||
|
const fmt = (v: number | null) => (v == null || !Number.isFinite(v) ? 'null' : v.toFixed(3));
|
||||||
|
const key = [
|
||||||
|
normDedupeKey,
|
||||||
|
payload.volume.toFixed(4),
|
||||||
|
fmt(payload.replayGainDb),
|
||||||
|
fmt(payload.replayGainPeak),
|
||||||
|
fmt(payload.loudnessGainDb),
|
||||||
|
payload.preGainDb.toFixed(2),
|
||||||
|
payload.fallbackDb.toFixed(2),
|
||||||
|
].join('|');
|
||||||
|
const now = Date.now();
|
||||||
|
if (key === lastRgInvokeKey && now - lastRgInvokeAtMs < REPLAY_GAIN_DEDUPE_WINDOW_MS) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastRgInvokeKey = key;
|
||||||
|
lastRgInvokeAtMs = now;
|
||||||
|
invoke('audio_update_replay_gain', payload).catch(console.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test-only: clear the cached dedupe state so each spec starts fresh. */
|
||||||
|
export function _resetNormalizationIpcDedupeForTest(): void {
|
||||||
|
lastNormAudioInvokeKey = '';
|
||||||
|
lastNormAudioInvokeAtMs = 0;
|
||||||
|
lastRgInvokeKey = '';
|
||||||
|
lastRgInvokeAtMs = 0;
|
||||||
|
}
|
||||||
@@ -65,6 +65,10 @@ import {
|
|||||||
schedulePauseTimer,
|
schedulePauseTimer,
|
||||||
scheduleResumeTimer,
|
scheduleResumeTimer,
|
||||||
} from './scheduleTimers';
|
} from './scheduleTimers';
|
||||||
|
import {
|
||||||
|
invokeAudioSetNormalizationDeduped,
|
||||||
|
invokeAudioUpdateReplayGainDeduped,
|
||||||
|
} from './normalizationIpcDedupe';
|
||||||
|
|
||||||
// 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,
|
||||||
@@ -648,77 +652,6 @@ function bumpWaveformRefreshGen(trackId: string) {
|
|||||||
* preload + current-track completion can stack two SQLite reads + two state writes. */
|
* preload + current-track completion can stack two SQLite reads + two state writes. */
|
||||||
const loudnessRefreshInflight = new Map<string, Promise<void>>();
|
const loudnessRefreshInflight = new Map<string, Promise<void>>();
|
||||||
|
|
||||||
/** Skip redundant `audio_set_normalization` IPC when the same payload is sent twice within a short window (e.g. StrictMode). */
|
|
||||||
let lastNormAudioInvokeKey = '';
|
|
||||||
let lastNormAudioInvokeAtMs = 0;
|
|
||||||
|
|
||||||
function invokeAudioSetNormalizationDeduped(payload: {
|
|
||||||
engine: string;
|
|
||||||
targetLufs: number;
|
|
||||||
preAnalysisAttenuationDb: number;
|
|
||||||
}) {
|
|
||||||
const key = `${payload.engine}|${payload.targetLufs}|${payload.preAnalysisAttenuationDb}`;
|
|
||||||
const now = Date.now();
|
|
||||||
if (key === lastNormAudioInvokeKey && now - lastNormAudioInvokeAtMs < 450) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
lastNormAudioInvokeKey = key;
|
|
||||||
lastNormAudioInvokeAtMs = now;
|
|
||||||
void invoke('audio_set_normalization', payload).catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Skip redundant `audio_update_replay_gain` IPC when the same payload was sent
|
|
||||||
* recently. updateReplayGainForCurrentTrack runs from the analysis:loudness-partial
|
|
||||||
* listener (~every 900 ms while LUFS is on); without dedupe each tick triggers a
|
|
||||||
* full IPC roundtrip + backend audio:normalization-state echo + frontend setState,
|
|
||||||
* which saturates the WebView2 renderer thread on Windows after a few minutes.
|
|
||||||
*/
|
|
||||||
let lastRgInvokeKey = '';
|
|
||||||
let lastRgInvokeAtMs = 0;
|
|
||||||
|
|
||||||
function invokeAudioUpdateReplayGainDeduped(payload: {
|
|
||||||
volume: number;
|
|
||||||
replayGainDb: number | null;
|
|
||||||
replayGainPeak: number | null;
|
|
||||||
loudnessGainDb: number | null;
|
|
||||||
preGainDb: number;
|
|
||||||
fallbackDb: number;
|
|
||||||
}) {
|
|
||||||
const auth = useAuthStore.getState();
|
|
||||||
/** Must vary when LUFS target / pre-trim changes: Rust recomputes in `audio_update_replay_gain` even if JS still sends the same cached dB. */
|
|
||||||
const preEff =
|
|
||||||
auth.normalizationEngine === 'loudness'
|
|
||||||
? effectiveLoudnessPreAnalysisAttenuationDb(
|
|
||||||
auth.loudnessPreAnalysisAttenuationDb,
|
|
||||||
auth.loudnessTargetLufs,
|
|
||||||
)
|
|
||||||
: auth.loudnessPreAnalysisAttenuationDb;
|
|
||||||
const normDedupeKey =
|
|
||||||
auth.normalizationEngine === 'loudness'
|
|
||||||
? `loudness|tgt=${auth.loudnessTargetLufs}|pre=${preEff.toFixed(2)}`
|
|
||||||
: auth.normalizationEngine === 'replaygain'
|
|
||||||
? 'replaygain'
|
|
||||||
: 'off';
|
|
||||||
const fmt = (v: number | null) => (v == null || !Number.isFinite(v) ? 'null' : v.toFixed(3));
|
|
||||||
const key = [
|
|
||||||
normDedupeKey,
|
|
||||||
payload.volume.toFixed(4),
|
|
||||||
fmt(payload.replayGainDb),
|
|
||||||
fmt(payload.replayGainPeak),
|
|
||||||
fmt(payload.loudnessGainDb),
|
|
||||||
payload.preGainDb.toFixed(2),
|
|
||||||
payload.fallbackDb.toFixed(2),
|
|
||||||
].join('|');
|
|
||||||
const now = Date.now();
|
|
||||||
if (key === lastRgInvokeKey && now - lastRgInvokeAtMs < 250) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
lastRgInvokeKey = key;
|
|
||||||
lastRgInvokeAtMs = now;
|
|
||||||
invoke('audio_update_replay_gain', payload).catch(console.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetLoudnessBackfillStateForTrackId(trackId: string) {
|
function resetLoudnessBackfillStateForTrackId(trackId: string) {
|
||||||
for (const k of loudnessCacheStateKeysForTrackId(trackId)) {
|
for (const k of loudnessCacheStateKeysForTrackId(trackId)) {
|
||||||
delete analysisBackfillInFlightByTrackId[k];
|
delete analysisBackfillInFlightByTrackId[k];
|
||||||
|
|||||||
Reference in New Issue
Block a user