mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +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
+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,
|
||||
|
||||
Reference in New Issue
Block a user