From 86b13dd4d00b12c99268bb3ffb22164e0303a9ba Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Tue, 12 May 2026 14:07:44 +0200 Subject: [PATCH] =?UTF-8?q?refactor(player):=20E.10=20=E2=80=94=20extract?= =?UTF-8?q?=20normalization-IPC=20deduplicators=20(#573)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- src/store/normalizationIpcDedupe.test.ts | 130 +++++++++++++++++++++++ src/store/normalizationIpcDedupe.ts | 92 ++++++++++++++++ src/store/playerStore.ts | 75 +------------ 3 files changed, 226 insertions(+), 71 deletions(-) create mode 100644 src/store/normalizationIpcDedupe.test.ts create mode 100644 src/store/normalizationIpcDedupe.ts diff --git a/src/store/normalizationIpcDedupe.test.ts b/src/store/normalizationIpcDedupe.test.ts new file mode 100644 index 00000000..11259aca --- /dev/null +++ b/src/store/normalizationIpcDedupe.test.ts @@ -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) => 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); + }); +}); diff --git a/src/store/normalizationIpcDedupe.ts b/src/store/normalizationIpcDedupe.ts new file mode 100644 index 00000000..cc76445c --- /dev/null +++ b/src/store/normalizationIpcDedupe.ts @@ -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; +} diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index a02c3b67..ec91f368 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -65,6 +65,10 @@ import { schedulePauseTimer, scheduleResumeTimer, } from './scheduleTimers'; +import { + invokeAudioSetNormalizationDeduped, + invokeAudioUpdateReplayGainDeduped, +} from './normalizationIpcDedupe'; // Re-export the playback-progress public surface so existing call sites // (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. */ const loudnessRefreshInflight = new Map>(); -/** 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) { for (const k of loudnessCacheStateKeysForTrackId(trackId)) { delete analysisBackfillInFlightByTrackId[k];