mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
refactor(player): E.19 — extract analysis-refresh helpers cluster (#583)
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.
This commit is contained in:
committed by
GitHub
parent
4c64844349
commit
7dc4888a06
@@ -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<string, unknown>) => 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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, Promise<void>>();
|
||||
|
||||
/**
|
||||
* 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<void> {
|
||||
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<void> {
|
||||
emitNormalizationDebug('refresh:start', { trackId });
|
||||
usePlayerStore.setState({ normalizationDbgSource: 'refresh:start', normalizationDbgTrackId: trackId });
|
||||
try {
|
||||
const requestedTarget = useAuthStore.getState().loudnessTargetLufs;
|
||||
const row = await invoke<LoudnessCachePayload | null>('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();
|
||||
}
|
||||
+6
-162
@@ -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<string, Promise<void>>();
|
||||
|
||||
async function refreshWaveformForTrack(trackId: string) {
|
||||
if (!trackId) return;
|
||||
const gen = getWaveformRefreshGen(trackId);
|
||||
try {
|
||||
const row = await invoke<WaveformCachePayload | null>('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<void> {
|
||||
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<void> {
|
||||
emitNormalizationDebug('refresh:start', { trackId });
|
||||
usePlayerStore.setState({ normalizationDbgSource: 'refresh:start', normalizationDbgTrackId: trackId });
|
||||
try {
|
||||
const requestedTarget = useAuthStore.getState().loudnessTargetLufs;
|
||||
const row = await invoke<LoudnessCachePayload | null>('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,
|
||||
|
||||
@@ -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<string, unknown>) => 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();
|
||||
});
|
||||
});
|
||||
@@ -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<void> {
|
||||
if (!trackId) return;
|
||||
const gen = getWaveformRefreshGen(trackId);
|
||||
try {
|
||||
const row = await invoke<WaveformCachePayload | null>('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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user