refactor(playback): move the audio engine into features/playback

Relocate the playback/queue/transport/audio-output engine out of the type-first
store/ + utils/playback/ + utils/audio/ dirs into a cohesive src/features/playback/,
structure-preserving:
  store/<x>                    -> features/playback/store/<x>
  store/audioListenerSetup/<x> -> features/playback/store/audioListenerSetup/<x>
  utils/playback/<x>           -> features/playback/utils/playback/<x>
  utils/audio/<x>              -> features/playback/utils/audio/<x>

184 files moved (107 source + 77 tests), 365 consumers rewritten. Pure move — no
behavior change, no state-split (the playerStore state-split stays a separate M5
question). Enabled by this session's decouple seams (artist/offline/orbit/auth →
core registries), so the engine carries no inbound core->feature inversion: store/
now holds only the 50 cross-cutting global stores (auth family, the seams, library
index, UI/settings stores).

KEPT OUT of the move (would re-create global->engine edges): the 3 pure config
helpers utils/audio/{loudnessPreAnalysisSlider,hiResCrossfadeResample} +
utils/playback/autodjOverlapCap (authStore + settings UI read them — they stay in
utils/). Ambiguous view-state stores (eqStore, queueToolbarStore,
playerBarLayoutStore) stay global (no engine imports).

Consumers use DEEP paths (@/features/playback/...), no barrel — matches the lib/
approach and avoids barrel-mock-collapse across the 140 usePlayerStore consumers.
Two tolerated type-only core->feature edges remain (localPlaybackStore->QueueItemRef,
localPlaybackMigration->HotCacheEntry, both erased).

tsc 0, lint 0, full suite 319/2353 green, iron-rule clean (no runtime store->feature
import). Behavior-touching only via the prerequisite bridge seam (already QA-flagged);
the move itself is pure.
This commit is contained in:
Psychotoxical
2026-06-30 15:00:20 +02:00
parent 6651abbc6f
commit cb1a110afb
393 changed files with 1127 additions and 1127 deletions
@@ -0,0 +1,195 @@
import { invoke } from '@tauri-apps/api/core';
import { useAuthStore } from '@/store/authStore';
import { autodjMaxOverlapCapSec } from '@/utils/playback/autodjOverlapCap';
import { computeWaveformSilence, planCrossfadeTransition } from '@/utils/waveform/waveformSilence';
import { findLocalPlaybackUrl } from '@/store/localPlaybackResolve';
import { playbackCacheKeyForRef } from '@/features/playback/utils/playback/playbackServer';
import { resolvePlaybackUrl } from '@/features/playback/utils/playback/resolvePlaybackUrl';
import { resolveQueueTrack } from '@/utils/library/queueTrackView';
import type { Track } from '@/features/playback/store/playerStoreTypes';
import {
hasPlannedCrossfade,
markPlannedCrossfade,
setCrossfadeTransition,
} from '@/features/playback/store/crossfadeTrimCache';
import { getBytePreloadingId, setBytePreloadingId } from '@/features/playback/store/gaplessPreloadState';
import { refreshLoudnessForTrack } from '@/features/playback/store/loudnessRefresh';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { fetchWaveformBins } from '@/features/playback/store/waveformRefresh';
// Crossfade pre-buffer budget: begin downloading the next track this many
// seconds before it needs to play (the crossfade start), so a large lossless
// file over HTTP has time to buffer + promote to cache before the fade. Generous
// on purpose. The trailing-silence trim widens the window further so the early
// A-tail advance keeps the full budget.
export const CROSSFADE_PRELOAD_BUDGET_SECS = 30;
/**
* Readiness gate for the AutoDJ early, content-driven advance. A stable fade
* needs the *next* track's audio at the overlap moment — analysis (waveform)
* alone is not enough. B counts as ready when its full bytes are in the engine
* RAM preload slot (`enginePreloadedTrackId`) or it is local on disk: offline
* library, favourite auto-sync, or hot-cache ephemeral tier. When B isn't ready
* we skip the early advance and let the plain engine crossfade handle the
* transition (graceful degrade) instead of fading over a buffering stream.
*/
export function isCrossfadeNextReady(
trackId: string,
profileId: string | null,
cacheKey: string | null,
): boolean {
if (!trackId) return false;
if (usePlayerStore.getState().enginePreloadedTrackId === trackId) return true;
for (const sid of [profileId, cacheKey]) {
if (!sid) continue;
if (
findLocalPlaybackUrl(trackId, sid, 'library')
|| findLocalPlaybackUrl(trackId, sid, 'favorite-auto')
|| findLocalPlaybackUrl(trackId, sid, 'ephemeral')
) {
return true;
}
}
return false;
}
/** Outgoing fade + preload window before an interrupt handoff (library pick, etc.). */
export const INTERRUPT_BLEND_PREP_FADE_SEC = 1.0;
/** @deprecated Use {@link INTERRUPT_BLEND_PREP_FADE_SEC} — prep and wait are aligned. */
export const INTERRUPT_BLEND_PRELOAD_WAIT_MS = Math.round(INTERRUPT_BLEND_PREP_FADE_SEC * 1000);
/**
* Start an eager RAM preload for a track the user just picked (no queue lead time).
* No-op when already ready or a preload for this id is in flight.
*/
export function kickEagerCrossfadePreload(
track: Track,
profileId: string | null,
cacheKey: string | null,
): void {
if (isCrossfadeNextReady(track.id, profileId, cacheKey)) return;
if (track.id === getBytePreloadingId()) return;
const serverId = cacheKey || profileId;
const url = resolvePlaybackUrl(track.id, serverId ?? undefined);
setBytePreloadingId(track.id);
void refreshLoudnessForTrack(track.id, { syncPlayingEngine: false });
invoke('audio_preload', {
url,
durationHint: track.duration,
analysisTrackId: track.id,
serverId: serverId || null,
eager: true,
}).catch(() => {});
}
function sleepMs(ms: number): Promise<void> {
return new Promise(resolve => { window.setTimeout(resolve, ms); });
}
/**
* Poll until B is playable for a stable crossfade, or `maxWaitMs` elapses.
* Returns false when `isStale()` reports a superseding play generation.
*/
export async function waitForCrossfadeNextReady(
trackId: string,
profileId: string | null,
cacheKey: string | null,
maxWaitMs: number,
isStale: () => boolean,
): Promise<boolean> {
if (isCrossfadeNextReady(trackId, profileId, cacheKey)) return true;
const deadline = Date.now() + maxWaitMs;
while (Date.now() < deadline) {
if (isStale()) return false;
await sleepMs(50);
if (isCrossfadeNextReady(trackId, profileId, cacheKey)) return true;
}
return isCrossfadeNextReady(trackId, profileId, cacheKey);
}
/**
* Crossfade-only byte pre-download for the next track + (when trim is on) its
* leading-silence probe. Self-gating and idempotent (`bytePreloadingId` /
* `hasFetchedCrossfadeLead` guards), so it is safe to call every progress tick
* *and* immediately after a seek lands inside the pre-buffer window. No-ops for
* the gapless / hot-cache paths (those pre-buffer elsewhere).
*
* Lives in its own module so `seekAction` can call it without pulling in
* `audioEventHandlers` (which would close a `playerStore` import cycle).
*/
export function maybeCrossfadeBytePreload(currentTime: number, dur: number): void {
if (!(dur > 0)) return;
const {
gaplessEnabled, hotCacheEnabled, crossfadeEnabled, crossfadeSecs, crossfadeTrimSilence,
} = useAuthStore.getState();
if (!crossfadeEnabled || gaplessEnabled) return;
const store = usePlayerStore.getState();
const track = store.currentTrack;
if (!track || store.currentRadio) return;
const remaining = dur - currentTime;
if (!(remaining > 0)) return;
const curTrailSilenceSec = crossfadeTrimSilence
? computeWaveformSilence(store.waveformBins, dur).trailSilenceSec
: 0;
const crossfadeWindowSecs = crossfadeSecs + curTrailSilenceSec + CROSSFADE_PRELOAD_BUDGET_SECS;
if (remaining >= crossfadeWindowSecs) return;
const { queueItems, queueIndex, repeatMode } = store;
if (repeatMode === 'one') return;
const nextIdx = queueIndex + 1;
const nextRef = nextIdx < queueItems.length
? queueItems[nextIdx]
: (repeatMode === 'all' && queueItems.length > 0 ? queueItems[0] : null);
if (!nextRef) return;
const nextTrack = resolveQueueTrack(nextRef);
if (!nextTrack || nextTrack.id === track.id) return;
const serverId = playbackCacheKeyForRef(nextRef);
const nextUrl = resolvePlaybackUrl(nextTrack.id, serverId);
// Byte pre-download — skipped when the hot cache is on (it already keeps the
// upcoming queue on disk, which is also why hot cache makes the trim reliable:
// the next track is local → seekable → starts instantly past its lead silence).
if (!hotCacheEnabled && nextTrack.id !== getBytePreloadingId()) {
setBytePreloadingId(nextTrack.id);
// Loudness cache only — never refreshWaveformForTrack(next): it writes the
// global waveformBins and would replace the current track's seekbar.
void refreshLoudnessForTrack(nextTrack.id, { syncPlayingEngine: false });
invoke('audio_preload', {
url: nextUrl,
durationHint: nextTrack.duration,
analysisTrackId: nextTrack.id,
serverId: serverId || null,
// Crossfade/AutoDJ pre-buffer: skip the 8 s throttle so the RAM slot
// fills before the fade — without the hot cache this is the only source
// of B's bytes, and a late slot means no fade (or an audible jump).
eager: true,
}).catch(() => {});
}
// B-head + dynamic overlap: plan the whole transition once (no store write) so
// playTrack can start the incoming track past its dead head AND fade over a
// content-adaptive overlap. Pairs the current track's envelope (already in the
// store) with the next track's cached waveform; the alignment maths is cheap,
// so it runs regardless of hot cache (which otherwise skips the byte
// pre-download). Cold/un-analysed tracks fall back to a fixed overlap + no
// head trim → today's behaviour.
if (crossfadeTrimSilence && !hasPlannedCrossfade(nextTrack.id)) {
markPlannedCrossfade(nextTrack.id);
const planTrackId = nextTrack.id;
const planDuration = nextTrack.duration;
const curBins = store.waveformBins;
void fetchWaveformBins(planTrackId, serverId || null)
.then(nextBins => {
// Overlap is derived purely from the audio (fade-out / buildup); the
// user's crossfadeSecs is intentionally not a factor in this mode.
const maxOverlapSec = autodjMaxOverlapCapSec(useAuthStore.getState());
const plan = planCrossfadeTransition(curBins, dur, nextBins, planDuration, { maxOverlapSec });
setCrossfadeTransition(planTrackId, plan);
})
.catch(() => {});
}
}