From 7dc4888a06918093458e059ea7ae6228a81d59bd Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Tue, 12 May 2026 16:15:34 +0200 Subject: [PATCH] =?UTF-8?q?refactor(player):=20E.19=20=E2=80=94=20extract?= =?UTF-8?q?=20analysis-refresh=20helpers=20cluster=20(#583)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two thematic cuts in one PR: - `src/store/waveformRefresh.ts` — `refreshWaveformForTrack` plus its `WaveformCachePayload` type. Fetches the cached waveform row and applies bins to the player store, guarded by both the refresh generation snapshot and the current-track check so a stale read can't overwrite fresh data. - `src/store/loudnessRefresh.ts` — `refreshLoudnessForTrack` plus the `loudnessRefreshInflight` coalescing map and `LoudnessCachePayload` type. Orchestrates the loudness fetch: dedupe concurrent calls by (trackId, syncEngine, target), distinguish hit vs miss, enqueue bounded backfill, suppress stale-target results by recursive retry. Both file-private; no caller-side changes outside playerStore's own imports. Imports of helpers that were only used by these two functions get cleaned up out of playerStore (coerceWaveformBins, getBackfillAttempts, forgetLoudnessGain, redactSubsonicUrlForLog, LOUDNESS_BACKFILL_WINDOW_AHEAD, isTrackInsideLoudnessBackfillWindow, etc.). 20 tests across the two modules pin the orchestration: gen + current-track guards on waveform; coalesce + hit/miss/backfill/stale-target/sync-flag branches on loudness. playerStore 3139 → 2983 LOC — first sub-3000 milestone for Phase E. --- src/store/loudnessRefresh.test.ts | 196 ++++++++++++++++++++++++++++++ src/store/loudnessRefresh.ts | 146 ++++++++++++++++++++++ src/store/playerStore.ts | 168 +------------------------ src/store/waveformRefresh.test.ts | 99 +++++++++++++++ src/store/waveformRefresh.ts | 45 +++++++ 5 files changed, 492 insertions(+), 162 deletions(-) create mode 100644 src/store/loudnessRefresh.test.ts create mode 100644 src/store/loudnessRefresh.ts create mode 100644 src/store/waveformRefresh.test.ts create mode 100644 src/store/waveformRefresh.ts diff --git a/src/store/loudnessRefresh.test.ts b/src/store/loudnessRefresh.test.ts new file mode 100644 index 00000000..0487a2e7 --- /dev/null +++ b/src/store/loudnessRefresh.test.ts @@ -0,0 +1,196 @@ +/** + * `refreshLoudnessForTrack` orchestrates the loudness analysis fetch: + * coalesce concurrent calls, distinguish hit vs miss, enqueue backfill + * within bounds, suppress stale-target results. The individual helpers + * (cache, backfill state, window predicate, debug emit) are tested in + * their own modules — this file pins the orchestration only. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const hoisted = vi.hoisted(() => { + const auth = { + loudnessTargetLufs: -14, + normalizationEngine: 'loudness' as 'off' | 'replaygain' | 'loudness', + }; + const playerState = { + queue: [] as Array<{ id: string }>, + queueIndex: 0, + currentTrack: null as { id: string } | null, + updateReplayGainForCurrentTrack: vi.fn(), + }; + return { + auth, + playerState, + invokeMock: vi.fn(async (_cmd: string, _args?: Record) => null as unknown), + buildStreamUrlMock: vi.fn((id: string) => `https://mock/stream/${id}`), + redactMock: vi.fn((s: string) => s), + playerSetStateMock: vi.fn(), + emitDebugMock: vi.fn(), + forgetLoudnessMock: vi.fn(), + markLoudnessStableMock: vi.fn(), + getBackfillAttemptsMock: vi.fn(() => 0), + isBackfillInFlightMock: vi.fn(() => false), + markBackfillInFlightMock: vi.fn(), + clearBackfillInFlightMock: vi.fn(), + resetBackfillAttemptsMock: vi.fn(), + isTrackInsideWindowMock: vi.fn(() => true), + }; +}); + +vi.mock('@tauri-apps/api/core', () => ({ invoke: hoisted.invokeMock })); +vi.mock('../api/subsonic', () => ({ buildStreamUrl: hoisted.buildStreamUrlMock })); +vi.mock('../utils/redactSubsonicUrl', () => ({ redactSubsonicUrlForLog: hoisted.redactMock })); +vi.mock('./authStore', () => ({ useAuthStore: { getState: () => hoisted.auth } })); +vi.mock('./playerStore', () => ({ + usePlayerStore: { + getState: () => hoisted.playerState, + setState: hoisted.playerSetStateMock, + }, +})); +vi.mock('./normalizationDebug', () => ({ emitNormalizationDebug: hoisted.emitDebugMock })); +vi.mock('./loudnessGainCache', () => ({ + forgetLoudnessGain: hoisted.forgetLoudnessMock, + markLoudnessStable: hoisted.markLoudnessStableMock, +})); +vi.mock('./loudnessBackfillState', () => ({ + MAX_BACKFILL_ATTEMPTS_PER_TRACK: 2, + clearBackfillInFlight: hoisted.clearBackfillInFlightMock, + getBackfillAttempts: hoisted.getBackfillAttemptsMock, + isBackfillInFlight: hoisted.isBackfillInFlightMock, + markBackfillInFlight: hoisted.markBackfillInFlightMock, + resetBackfillAttempts: hoisted.resetBackfillAttemptsMock, +})); +vi.mock('./loudnessBackfillWindow', () => ({ + LOUDNESS_BACKFILL_WINDOW_AHEAD: 5, + isTrackInsideLoudnessBackfillWindow: hoisted.isTrackInsideWindowMock, +})); + +import { + _resetLoudnessRefreshInflightForTest, + refreshLoudnessForTrack, +} from './loudnessRefresh'; + +beforeEach(() => { + _resetLoudnessRefreshInflightForTest(); + hoisted.auth.loudnessTargetLufs = -14; + hoisted.auth.normalizationEngine = 'loudness'; + hoisted.playerState.queue = []; + hoisted.playerState.queueIndex = 0; + hoisted.playerState.currentTrack = null; + hoisted.invokeMock.mockReset(); + hoisted.invokeMock.mockResolvedValue(null); + hoisted.playerSetStateMock.mockClear(); + hoisted.emitDebugMock.mockClear(); + hoisted.forgetLoudnessMock.mockClear(); + hoisted.markLoudnessStableMock.mockClear(); + hoisted.markBackfillInFlightMock.mockClear(); + hoisted.clearBackfillInFlightMock.mockClear(); + hoisted.resetBackfillAttemptsMock.mockClear(); + hoisted.getBackfillAttemptsMock.mockReset(); + hoisted.getBackfillAttemptsMock.mockReturnValue(0); + hoisted.isBackfillInFlightMock.mockReset(); + hoisted.isBackfillInFlightMock.mockReturnValue(false); + hoisted.isTrackInsideWindowMock.mockReset(); + hoisted.isTrackInsideWindowMock.mockReturnValue(true); + hoisted.playerState.updateReplayGainForCurrentTrack = vi.fn(); +}); + +describe('refreshLoudnessForTrack', () => { + it('is a no-op for empty trackId', async () => { + await refreshLoudnessForTrack(''); + expect(hoisted.invokeMock).not.toHaveBeenCalled(); + }); + + it('coalesces concurrent calls for the same key into one inflight promise', async () => { + hoisted.invokeMock.mockResolvedValue(null); + const p1 = refreshLoudnessForTrack('t1'); + const p2 = refreshLoudnessForTrack('t1'); + await Promise.all([p1, p2]); + // One analysis_get_loudness_for_track call shared between both awaiters. + const getCalls = hoisted.invokeMock.mock.calls.filter(c => c[0] === 'analysis_get_loudness_for_track'); + expect(getCalls).toHaveLength(1); + }); + + it('marks loudness stable on a hit row', async () => { + hoisted.invokeMock.mockResolvedValueOnce({ recommendedGainDb: -7, targetLufs: -14, updatedAt: 123 }); + await refreshLoudnessForTrack('t1'); + expect(hoisted.markLoudnessStableMock).toHaveBeenCalledWith('t1', -7); + expect(hoisted.resetBackfillAttemptsMock).toHaveBeenCalledWith('t1'); + }); + + it('forgets the cached value on a miss row', async () => { + hoisted.invokeMock.mockResolvedValueOnce(null); + await refreshLoudnessForTrack('t1'); + expect(hoisted.forgetLoudnessMock).toHaveBeenCalledWith('t1'); + }); + + it('enqueues a backfill when conditions are met (loudness engine, not inflight, attempts < max, in window)', async () => { + hoisted.invokeMock.mockResolvedValueOnce(null); + await refreshLoudnessForTrack('t1'); + expect(hoisted.markBackfillInFlightMock).toHaveBeenCalledWith('t1', 1); + const enqueueCall = hoisted.invokeMock.mock.calls.find(c => c[0] === 'analysis_enqueue_seed_from_url'); + expect(enqueueCall).toBeDefined(); + }); + + it('skips backfill when outside the prefetch window', async () => { + hoisted.invokeMock.mockResolvedValueOnce(null); + hoisted.isTrackInsideWindowMock.mockReturnValueOnce(false); + await refreshLoudnessForTrack('t1'); + expect(hoisted.markBackfillInFlightMock).not.toHaveBeenCalled(); + const enqueueCall = hoisted.invokeMock.mock.calls.find(c => c[0] === 'analysis_enqueue_seed_from_url'); + expect(enqueueCall).toBeUndefined(); + }); + + it('skips backfill when attempts already at max', async () => { + hoisted.invokeMock.mockResolvedValueOnce(null); + hoisted.getBackfillAttemptsMock.mockReturnValueOnce(2); + await refreshLoudnessForTrack('t1'); + expect(hoisted.markBackfillInFlightMock).not.toHaveBeenCalled(); + const throttledCalls = hoisted.emitDebugMock.mock.calls.filter(c => c[0] === 'backfill:throttled'); + expect(throttledCalls.length).toBeGreaterThan(0); + }); + + it('skips backfill when already inflight', async () => { + hoisted.invokeMock.mockResolvedValueOnce(null); + hoisted.isBackfillInFlightMock.mockReturnValueOnce(true); + await refreshLoudnessForTrack('t1'); + expect(hoisted.markBackfillInFlightMock).not.toHaveBeenCalled(); + }); + + it('discards results and retries when the LUFS target changes mid-flight', async () => { + hoisted.invokeMock.mockImplementationOnce(async () => { + hoisted.auth.loudnessTargetLufs = -10; // target changes during await + return { recommendedGainDb: -5, targetLufs: -14, updatedAt: 1 }; + }); + hoisted.invokeMock.mockResolvedValueOnce(null); // retry returns miss + await refreshLoudnessForTrack('t1'); + // markLoudnessStable should NOT have been called from the first invocation — + // result is discarded because target changed. + expect(hoisted.markLoudnessStableMock).not.toHaveBeenCalled(); + const staleCalls = hoisted.emitDebugMock.mock.calls.filter(c => c[0] === 'refresh:stale-target'); + expect(staleCalls).toHaveLength(1); + // Drain pending recursive retries spawned via `void refreshLoudnessForTrack(...)` + // so they don't bleed into the next test's mock queue. + for (let i = 0; i < 10; i++) await Promise.resolve(); + }); + + it('skips engine update when syncPlayingEngine is false', async () => { + hoisted.invokeMock.mockResolvedValueOnce({ recommendedGainDb: -7, targetLufs: -14, updatedAt: 1 }); + await refreshLoudnessForTrack('t1', { syncPlayingEngine: false }); + expect(hoisted.playerState.updateReplayGainForCurrentTrack).not.toHaveBeenCalled(); + }); + + it('calls updateReplayGainForCurrentTrack by default on hit', async () => { + hoisted.invokeMock.mockResolvedValueOnce({ recommendedGainDb: -7, targetLufs: -14, updatedAt: 1 }); + await refreshLoudnessForTrack('t1'); + expect(hoisted.playerState.updateReplayGainForCurrentTrack).toHaveBeenCalledTimes(1); + }); + + it('forgets cache + emits refresh:error on a thrown invoke', async () => { + hoisted.invokeMock.mockRejectedValueOnce(new Error('rust busy')); + await refreshLoudnessForTrack('t1'); + expect(hoisted.forgetLoudnessMock).toHaveBeenCalledWith('t1'); + const errCalls = hoisted.emitDebugMock.mock.calls.filter(c => c[0] === 'refresh:error'); + expect(errCalls).toHaveLength(1); + }); +}); diff --git a/src/store/loudnessRefresh.ts b/src/store/loudnessRefresh.ts new file mode 100644 index 00000000..f87d4a0f --- /dev/null +++ b/src/store/loudnessRefresh.ts @@ -0,0 +1,146 @@ +import { invoke } from '@tauri-apps/api/core'; +import { buildStreamUrl } from '../api/subsonic'; +import { redactSubsonicUrlForLog } from '../utils/redactSubsonicUrl'; +import { useAuthStore } from './authStore'; +import { usePlayerStore } from './playerStore'; +import { emitNormalizationDebug } from './normalizationDebug'; +import { + forgetLoudnessGain, + markLoudnessStable, +} from './loudnessGainCache'; +import { + MAX_BACKFILL_ATTEMPTS_PER_TRACK, + clearBackfillInFlight, + getBackfillAttempts, + isBackfillInFlight, + markBackfillInFlight, + resetBackfillAttempts, +} from './loudnessBackfillState'; +import { + LOUDNESS_BACKFILL_WINDOW_AHEAD, + isTrackInsideLoudnessBackfillWindow, +} from './loudnessBackfillWindow'; + +/** Subsonic-server loudness-cache row as Rust hands it back. */ +type LoudnessCachePayload = { + integratedLufs: number; + truePeak: number; + recommendedGainDb: number; + targetLufs: number; + updatedAt: number; +}; + +/** + * Coalesce concurrent `analysis_get_loudness_for_track` for one id+mode + * pair. The `analysis:waveform-updated` listener fires refreshWaveform + + * refreshLoudness in parallel for every full-track analysis completion; + * without coalescing, gapless preload + current-track completion can + * stack two SQLite reads + two state writes. + */ +const loudnessRefreshInflight = new Map>(); + +/** + * Fetch the loudness gain for `trackId` from Rust and apply it to the + * loudness-gain cache + player-store debug fields. When `syncPlayingEngine` + * is false (default true), the engine is NOT asked to update its + * replay-gain — used when prefetching neighbour tracks. + * + * Coalesces by (trackId, syncEngine, target) so concurrent calls share a + * single inflight promise. + */ +export async function refreshLoudnessForTrack( + trackId: string, + opts?: { syncPlayingEngine?: boolean }, +): Promise { + if (!trackId) return; + const syncEngine = opts?.syncPlayingEngine !== false; + const target = useAuthStore.getState().loudnessTargetLufs; + const inflightKey = `${trackId}|${syncEngine ? 'sync' : 'no-sync'}|${target}`; + const existing = loudnessRefreshInflight.get(inflightKey); + if (existing) return existing; + const job = (async () => { await runRefreshLoudnessForTrack(trackId, syncEngine); })() + .finally(() => { loudnessRefreshInflight.delete(inflightKey); }); + loudnessRefreshInflight.set(inflightKey, job); + return job; +} + +async function runRefreshLoudnessForTrack(trackId: string, syncEngine: boolean): Promise { + emitNormalizationDebug('refresh:start', { trackId }); + usePlayerStore.setState({ normalizationDbgSource: 'refresh:start', normalizationDbgTrackId: trackId }); + try { + const requestedTarget = useAuthStore.getState().loudnessTargetLufs; + const row = await invoke('analysis_get_loudness_for_track', { + trackId, + targetLufs: requestedTarget, + }); + if (useAuthStore.getState().loudnessTargetLufs !== requestedTarget) { + emitNormalizationDebug('refresh:stale-target', { trackId, requestedTarget }); + void refreshLoudnessForTrack(trackId, { syncPlayingEngine: syncEngine }); + return; + } + if (!row || !Number.isFinite(row.recommendedGainDb)) { + forgetLoudnessGain(trackId); + emitNormalizationDebug('refresh:miss', { trackId, row: row ?? null }); + const auth = useAuthStore.getState(); + const attempts = getBackfillAttempts(trackId); + if (auth.normalizationEngine === 'loudness' + && !isBackfillInFlight(trackId) + && attempts < MAX_BACKFILL_ATTEMPTS_PER_TRACK) { + const live = usePlayerStore.getState(); + if (!isTrackInsideLoudnessBackfillWindow(trackId, live.queue, live.queueIndex, live.currentTrack)) { + emitNormalizationDebug('backfill:skipped-outside-window', { + trackId, + queueIndex: live.queueIndex, + aheadWindow: LOUDNESS_BACKFILL_WINDOW_AHEAD, + }); + return; + } + markBackfillInFlight(trackId, attempts + 1); + const url = buildStreamUrl(trackId); + emitNormalizationDebug('backfill:enqueue', { + trackId, + url: redactSubsonicUrlForLog(url), + attempt: attempts + 1, + }); + void invoke('analysis_enqueue_seed_from_url', { trackId, url }) + .then(() => emitNormalizationDebug('backfill:queued', { trackId, attempt: attempts + 1 })) + .catch((e) => emitNormalizationDebug('backfill:error', { trackId, error: String(e) })) + .finally(() => { + clearBackfillInFlight(trackId); + }); + } else if (auth.normalizationEngine === 'loudness' && attempts >= MAX_BACKFILL_ATTEMPTS_PER_TRACK) { + emitNormalizationDebug('backfill:throttled', { trackId, attempts }); + } + usePlayerStore.setState({ + normalizationDbgSource: 'refresh:miss', + normalizationDbgTrackId: trackId, + normalizationDbgCacheGainDb: null, + normalizationDbgCacheTargetLufs: Number.isFinite(row?.targetLufs as number) ? (row?.targetLufs as number) : null, + normalizationDbgCacheUpdatedAt: Number.isFinite(row?.updatedAt as number) ? (row?.updatedAt as number) : null, + }); + return; + } + markLoudnessStable(trackId, row.recommendedGainDb); + resetBackfillAttempts(trackId); + emitNormalizationDebug('refresh:hit', { trackId, row }); + usePlayerStore.setState({ + normalizationDbgSource: 'refresh:hit', + normalizationDbgTrackId: trackId, + normalizationDbgCacheGainDb: row.recommendedGainDb, + normalizationDbgCacheTargetLufs: Number.isFinite(row.targetLufs) ? row.targetLufs : null, + normalizationDbgCacheUpdatedAt: Number.isFinite(row.updatedAt) ? row.updatedAt : null, + }); + if (syncEngine) { + usePlayerStore.getState().updateReplayGainForCurrentTrack(); + } + } catch { + forgetLoudnessGain(trackId); + emitNormalizationDebug('refresh:error', { trackId }); + usePlayerStore.setState({ normalizationDbgSource: 'refresh:error', normalizationDbgTrackId: trackId }); + } +} + +/** Test-only: drop pending refresh promises so each spec starts clean. */ +export function _resetLoudnessRefreshInflightForTest(): void { + loudnessRefreshInflight.clear(); +} diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index 306ebca4..1a2adfe6 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -6,7 +6,6 @@ import { showToast } from '../utils/toast'; import i18n from '../i18n'; import { buildCoverArtUrl, buildStreamUrl, getPlayQueue, savePlayQueue, reportNowPlaying, scrobbleSong, getSong, getSimilarSongs2, getTopSongs, InternetRadioStation, setRating, getAlbumInfo2 } from '../api/subsonic'; import { resolvePlaybackUrl, streamUrlTrackId, getPlaybackSourceKind, type PlaybackSourceKind } from '../utils/resolvePlaybackUrl'; -import { redactSubsonicUrlForLog } from '../utils/redactSubsonicUrl'; import { setDeferHotCachePrefetch } from '../utils/hotCacheGate'; import { lastfmScrobble, lastfmUpdateNowPlaying, lastfmLoveTrack, lastfmUnloveTrack, lastfmGetTrackLoved, lastfmGetAllLovedTracks } from '../api/lastfm'; import { useAuthStore } from './authStore'; @@ -30,7 +29,7 @@ import { sameQueueTrackId, shallowCloneQueueTracks, } from '../utils/queueIdentity'; -import { coerceWaveformBins, waveformBlobLenOk } from '../utils/waveformParse'; +import { waveformBlobLenOk } from '../utils/waveformParse'; import { normalizationAlmostEqual } from '../utils/normalizationCompare'; import { isRecoverableSeekError } from '../utils/seekErrors'; import { @@ -49,13 +48,11 @@ import { emitNormalizationDebug } from './normalizationDebug'; import { isInOrbitSession } from './orbitSession'; import { clearLoudnessCacheStateForTrackId, - forgetLoudnessGain, getCachedLoudnessGain, hasStableLoudness, isReplayGainActive, loudnessCacheStateKeysForTrackId, loudnessGainDbForEngineBind, - markLoudnessStable, setCachedLoudnessGain, } from './loudnessGainCache'; import { @@ -69,26 +66,11 @@ import { invokeAudioSetNormalizationDeduped, invokeAudioUpdateReplayGainDeduped, } from './normalizationIpcDedupe'; -import { - bumpWaveformRefreshGen, - getWaveformRefreshGen, -} from './waveformRefreshGen'; +import { bumpWaveformRefreshGen } from './waveformRefreshGen'; import { touchHotCacheOnPlayback } from './hotCacheTouch'; import { applySkipStarOnManualNext } from './skipStarRating'; -import { - MAX_BACKFILL_ATTEMPTS_PER_TRACK, - clearBackfillInFlight, - getBackfillAttempts, - isBackfillInFlight, - markBackfillInFlight, - resetBackfillAttempts, - resetLoudnessBackfillStateForTrackId, -} from './loudnessBackfillState'; -import { - LOUDNESS_BACKFILL_WINDOW_AHEAD, - collectLoudnessBackfillWindowTrackIds, - isTrackInsideLoudnessBackfillWindow, -} from './loudnessBackfillWindow'; +import { resetLoudnessBackfillStateForTrackId } from './loudnessBackfillState'; +import { collectLoudnessBackfillWindowTrackIds } from './loudnessBackfillWindow'; import { flushPlayQueuePosition, flushQueueSyncToServer, @@ -114,6 +96,8 @@ import { } from './seekTargetState'; import { tryAcquireTogglePlayLock } from './togglePlayLock'; import { reseedLoudnessForTrackId } from './loudnessReseed'; +import { refreshWaveformForTrack } from './waveformRefresh'; +import { refreshLoudnessForTrack } from './loudnessRefresh'; // Re-export so TauriEventBridge + persistence test keep their existing // `from './playerStore'` imports. @@ -356,24 +340,6 @@ export interface PlayerState { closeSongInfo: () => void; } -type WaveformCachePayload = { - /** May be `number[]` or `Uint8Array` depending on Tauri IPC / serde path. */ - bins: number[] | Uint8Array; - binCount: number; - isPartial: boolean; - knownUntilSec: number; - durationSec: number; - updatedAt: number; -}; - -type LoudnessCachePayload = { - integratedLufs: number; - truePeak: number; - recommendedGainDb: number; - targetLufs: number; - updatedAt: number; -}; - type NormalizationStatePayload = { engine: 'off' | 'replaygain' | 'loudness' | string; currentGainDb: number | null; @@ -625,128 +591,6 @@ radioAudio.addEventListener('suspend', () => { clearRadioReconnectTimer(); }); -/** Coalesce concurrent `analysis_get_loudness_for_track` for one id+mode pair. The - * analysis:waveform-updated listener fires refreshWaveform + refreshLoudness in - * parallel for every full-track analysis completion; without coalescing, gapless - * preload + current-track completion can stack two SQLite reads + two state writes. */ -const loudnessRefreshInflight = new Map>(); - -async function refreshWaveformForTrack(trackId: string) { - if (!trackId) return; - const gen = getWaveformRefreshGen(trackId); - try { - const row = await invoke('analysis_get_waveform_for_track', { trackId }); - if (getWaveformRefreshGen(trackId) !== gen) return; - // Never apply bins for a non-current track (e.g. gapless byte-preload fetches the neighbour). - if (usePlayerStore.getState().currentTrack?.id !== trackId) return; - const bins = row ? coerceWaveformBins(row.bins) : null; - if (!bins || bins.length === 0) { - usePlayerStore.setState({ - waveformBins: null, - }); - return; - } - usePlayerStore.setState({ - waveformBins: bins, - }); - } catch { - // best-effort; seekbar falls back to placeholder waveform - } -} - -/** When `syncPlayingEngine` is false, only update the loudness gain cache (e.g. queue neighbour) — do not call `audio_update_replay_gain` for the already-playing track. */ -async function refreshLoudnessForTrack( - trackId: string, - opts?: { syncPlayingEngine?: boolean }, -): Promise { - if (!trackId) return; - const syncEngine = opts?.syncPlayingEngine !== false; - const target = useAuthStore.getState().loudnessTargetLufs; - const inflightKey = `${trackId}|${syncEngine ? 'sync' : 'no-sync'}|${target}`; - const existing = loudnessRefreshInflight.get(inflightKey); - if (existing) return existing; - const job = (async () => { await runRefreshLoudnessForTrack(trackId, syncEngine); })() - .finally(() => { loudnessRefreshInflight.delete(inflightKey); }); - loudnessRefreshInflight.set(inflightKey, job); - return job; -} - -async function runRefreshLoudnessForTrack(trackId: string, syncEngine: boolean): Promise { - emitNormalizationDebug('refresh:start', { trackId }); - usePlayerStore.setState({ normalizationDbgSource: 'refresh:start', normalizationDbgTrackId: trackId }); - try { - const requestedTarget = useAuthStore.getState().loudnessTargetLufs; - const row = await invoke('analysis_get_loudness_for_track', { - trackId, - targetLufs: requestedTarget, - }); - if (useAuthStore.getState().loudnessTargetLufs !== requestedTarget) { - emitNormalizationDebug('refresh:stale-target', { trackId, requestedTarget }); - void refreshLoudnessForTrack(trackId, { syncPlayingEngine: syncEngine }); - return; - } - if (!row || !Number.isFinite(row.recommendedGainDb)) { - forgetLoudnessGain(trackId); - emitNormalizationDebug('refresh:miss', { trackId, row: row ?? null }); - const auth = useAuthStore.getState(); - const attempts = getBackfillAttempts(trackId); - if (auth.normalizationEngine === 'loudness' - && !isBackfillInFlight(trackId) - && attempts < MAX_BACKFILL_ATTEMPTS_PER_TRACK) { - const live = usePlayerStore.getState(); - if (!isTrackInsideLoudnessBackfillWindow(trackId, live.queue, live.queueIndex, live.currentTrack)) { - emitNormalizationDebug('backfill:skipped-outside-window', { - trackId, - queueIndex: live.queueIndex, - aheadWindow: LOUDNESS_BACKFILL_WINDOW_AHEAD, - }); - return; - } - markBackfillInFlight(trackId, attempts + 1); - const url = buildStreamUrl(trackId); - emitNormalizationDebug('backfill:enqueue', { - trackId, - url: redactSubsonicUrlForLog(url), - attempt: attempts + 1, - }); - void invoke('analysis_enqueue_seed_from_url', { trackId, url }) - .then(() => emitNormalizationDebug('backfill:queued', { trackId, attempt: attempts + 1 })) - .catch((e) => emitNormalizationDebug('backfill:error', { trackId, error: String(e) })) - .finally(() => { - clearBackfillInFlight(trackId); - }); - } else if (auth.normalizationEngine === 'loudness' && attempts >= MAX_BACKFILL_ATTEMPTS_PER_TRACK) { - emitNormalizationDebug('backfill:throttled', { trackId, attempts }); - } - usePlayerStore.setState({ - normalizationDbgSource: 'refresh:miss', - normalizationDbgTrackId: trackId, - normalizationDbgCacheGainDb: null, - normalizationDbgCacheTargetLufs: Number.isFinite(row?.targetLufs as number) ? (row?.targetLufs as number) : null, - normalizationDbgCacheUpdatedAt: Number.isFinite(row?.updatedAt as number) ? (row?.updatedAt as number) : null, - }); - return; - } - markLoudnessStable(trackId, row.recommendedGainDb); - resetBackfillAttempts(trackId); - emitNormalizationDebug('refresh:hit', { trackId, row }); - usePlayerStore.setState({ - normalizationDbgSource: 'refresh:hit', - normalizationDbgTrackId: trackId, - normalizationDbgCacheGainDb: row.recommendedGainDb, - normalizationDbgCacheTargetLufs: Number.isFinite(row.targetLufs) ? row.targetLufs : null, - normalizationDbgCacheUpdatedAt: Number.isFinite(row.updatedAt) ? row.updatedAt : null, - }); - if (syncEngine) { - usePlayerStore.getState().updateReplayGainForCurrentTrack(); - } - } catch { - forgetLoudnessGain(trackId); - emitNormalizationDebug('refresh:error', { trackId }); - usePlayerStore.setState({ normalizationDbgSource: 'refresh:error', normalizationDbgTrackId: trackId }); - } -} - function prefetchLoudnessForEnqueuedTracks( mergedQueue: Track[], queueIndex: number, diff --git a/src/store/waveformRefresh.test.ts b/src/store/waveformRefresh.test.ts new file mode 100644 index 00000000..f49d3981 --- /dev/null +++ b/src/store/waveformRefresh.test.ts @@ -0,0 +1,99 @@ +/** + * `refreshWaveformForTrack` fetches an analysis row from Rust and applies + * it to the player store — but only if the refresh generation hasn't been + * bumped meanwhile and the track is still current. The tests pin both + * guards and the success / null-row / empty-bins / error branches. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const hoisted = vi.hoisted(() => ({ + invokeMock: vi.fn(async (_cmd: string, _args?: Record) => null as unknown), + coerceWaveformBinsMock: vi.fn((bins: unknown) => { + if (bins == null) return null; + if (Array.isArray(bins) && bins.length === 0) return null; + return bins as number[]; + }), + playerSnapshot: { + currentTrack: null as { id: string } | null, + }, + playerSetStateMock: vi.fn(), + gen: 0, + getGenMock: vi.fn(() => hoisted.gen), +})); + +vi.mock('@tauri-apps/api/core', () => ({ invoke: hoisted.invokeMock })); +vi.mock('../utils/waveformParse', () => ({ coerceWaveformBins: hoisted.coerceWaveformBinsMock })); +vi.mock('./playerStore', () => ({ + usePlayerStore: { + getState: () => hoisted.playerSnapshot, + setState: hoisted.playerSetStateMock, + }, +})); +vi.mock('./waveformRefreshGen', () => ({ + getWaveformRefreshGen: hoisted.getGenMock, +})); + +import { refreshWaveformForTrack } from './waveformRefresh'; + +beforeEach(() => { + hoisted.invokeMock.mockReset(); + hoisted.invokeMock.mockResolvedValue(null); + hoisted.coerceWaveformBinsMock.mockClear(); + hoisted.playerSetStateMock.mockClear(); + hoisted.playerSnapshot.currentTrack = null; + hoisted.gen = 0; +}); + +describe('refreshWaveformForTrack', () => { + it('is a no-op for empty trackId', async () => { + await refreshWaveformForTrack(''); + expect(hoisted.invokeMock).not.toHaveBeenCalled(); + }); + + it('discards results when the gen has been bumped since the call started', async () => { + hoisted.playerSnapshot.currentTrack = { id: 't1' }; + hoisted.invokeMock.mockImplementationOnce(async () => { + hoisted.gen = 99; // simulate concurrent bump + return { bins: [1, 2, 3] }; + }); + await refreshWaveformForTrack('t1'); + expect(hoisted.playerSetStateMock).not.toHaveBeenCalled(); + }); + + it('skips when the track is no longer current after the fetch', async () => { + hoisted.playerSnapshot.currentTrack = { id: 'other' }; + hoisted.invokeMock.mockResolvedValueOnce({ bins: [1, 2, 3] }); + await refreshWaveformForTrack('t1'); + expect(hoisted.playerSetStateMock).not.toHaveBeenCalled(); + }); + + it('blanks bins when the row is null', async () => { + hoisted.playerSnapshot.currentTrack = { id: 't1' }; + hoisted.invokeMock.mockResolvedValueOnce(null); + await refreshWaveformForTrack('t1'); + expect(hoisted.playerSetStateMock).toHaveBeenCalledWith({ waveformBins: null }); + }); + + it('blanks bins when coerceWaveformBins returns null (invalid shape)', async () => { + hoisted.playerSnapshot.currentTrack = { id: 't1' }; + hoisted.invokeMock.mockResolvedValueOnce({ bins: 'garbage' }); + hoisted.coerceWaveformBinsMock.mockReturnValueOnce(null); + await refreshWaveformForTrack('t1'); + expect(hoisted.playerSetStateMock).toHaveBeenCalledWith({ waveformBins: null }); + }); + + it('applies the coerced bins on a clean fetch', async () => { + hoisted.playerSnapshot.currentTrack = { id: 't1' }; + hoisted.invokeMock.mockResolvedValueOnce({ bins: [10, 20, 30] }); + hoisted.coerceWaveformBinsMock.mockReturnValueOnce([10, 20, 30]); + await refreshWaveformForTrack('t1'); + expect(hoisted.playerSetStateMock).toHaveBeenCalledWith({ waveformBins: [10, 20, 30] }); + }); + + it('swallows fetch errors silently (placeholder waveform stays)', async () => { + hoisted.playerSnapshot.currentTrack = { id: 't1' }; + hoisted.invokeMock.mockRejectedValueOnce(new Error('boom')); + await expect(refreshWaveformForTrack('t1')).resolves.toBeUndefined(); + expect(hoisted.playerSetStateMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/store/waveformRefresh.ts b/src/store/waveformRefresh.ts new file mode 100644 index 00000000..6a288697 --- /dev/null +++ b/src/store/waveformRefresh.ts @@ -0,0 +1,45 @@ +import { invoke } from '@tauri-apps/api/core'; +import { coerceWaveformBins } from '../utils/waveformParse'; +import { usePlayerStore } from './playerStore'; +import { getWaveformRefreshGen } from './waveformRefreshGen'; + +/** Subsonic-server waveform-cache row as Rust hands it back. */ +export type WaveformCachePayload = { + /** May be `number[]` or `Uint8Array` depending on Tauri IPC / serde path. */ + bins: number[] | Uint8Array; + binCount: number; + isPartial: boolean; + knownUntilSec: number; + durationSec: number; + updatedAt: number; +}; + +/** + * Fetch the cached waveform row for `trackId` from Rust and apply its bins + * to the player store — but only if (a) the refresh generation snapshot + * still matches (no newer invalidation has fired meanwhile) and (b) the + * track is still the current one. Best-effort: any failure leaves the + * seekbar with the placeholder waveform. + */ +export async function refreshWaveformForTrack(trackId: string): Promise { + if (!trackId) return; + const gen = getWaveformRefreshGen(trackId); + try { + const row = await invoke('analysis_get_waveform_for_track', { trackId }); + if (getWaveformRefreshGen(trackId) !== gen) return; + // Never apply bins for a non-current track (e.g. gapless byte-preload fetches the neighbour). + if (usePlayerStore.getState().currentTrack?.id !== trackId) return; + const bins = row ? coerceWaveformBins(row.bins) : null; + if (!bins || bins.length === 0) { + usePlayerStore.setState({ + waveformBins: null, + }); + return; + } + usePlayerStore.setState({ + waveformBins: bins, + }); + } catch { + // best-effort; seekbar falls back to placeholder waveform + } +}