Files
SoulStream/src/lib/preview/service.js
T
KeelrLeek abc959ca2f fix: defer the open crossfade so main no longer fades into a gap
Opening the preview faded the main track out at focus-acquire time, but the
first preview clip only becomes audible after resolve/load/decode — so on
the way IN the 340 ms main fade completed into silence before the preview
arrived (a gap the user heard; the exit was already fine).

The main fade is now deferred: focusBridge.requestSilence only arms the
commitment and grants; coordinator.beginMainSilence performs the actual
fade-pause, and the service fires it from afterChange on the opening
session's first audible clip (or ITEM_FAILED). Main-cos-out then overlaps
preview-sin-in — a true cross. resumeMain skips the fade-resume when the
fade never fired (close before audible), and beginMainSilence re-checks the
main is sounding before fading. The focus coordinator's acquisition logic
and tests are untouched.

Verified live: preview audible at 275 ms, main played until then, faded
~343 ms, paused at 618 ms and held frozen; close resumed from the exact
position in 15 ms.
2026-07-23 16:54:09 -05:00

450 lines
20 KiB
JavaScript

// Preview session service — the effectful orchestrator over the pure parts.
//
// The reducer decides WHAT happens; this service decides WHEN: the settle
// window after navigation, the resolve→load→play pipeline, the single
// cache-bypass retry, the two-neighbour lease prefetch, and the focus
// handshake at the session edges. Everything time-like is injected
// (scheduler, monotonic clock) so the whole loop runs under node:test with
// manual ticks — no real timers anywhere in the tests.
//
// Staleness discipline: every async continuation re-checks BOTH the session
// id and the request generation before dispatching or touching the
// transport. The reducer would drop a stale dispatch anyway; checking here
// too keeps the transport from even being commanded on behalf of a dead
// generation (the "transport boundary" half of the Phase 2 exit gate).
import { reduce, createInitialState, playableManifestIndices, playableCursor, PHASE } from "./session.js";
const defaultScheduler = {
set: (fn, ms) => setTimeout(fn, ms),
clear: (id) => clearTimeout(id),
};
/** Equal-power crossfade between track previews. 340 ms — the EXACT length
* of the reel's card-slide transition (`top 340ms` in PreviewOverlay), so
* the audio cross and the visual slide start and land together (user
* decision 2026-07-23, superseding the earlier 250 ms pick). The outgoing
* clip keeps SOUNDING until the incoming one is loaded — the backend's
* crossfade owns both ramps — so the two genuinely overlap. */
export const CROSSFADE_MS = 340;
/** How long the outgoing clip may keep playing under the NEW card before we
* give up waiting for the incoming load and fade it out anyway. Only reached
* on a slow un-prefetched resolve; with the lease prefetch the load lands in
* well under one crossfade. */
const OUTGOING_OVERSTAY_MS = 1000;
/** Steps closer together than this are a burst (held arrow key, ~33 ms
* repeats) and get the coalescing settle window; an ISOLATED step fires the
* pipeline immediately — the user asked for audio "the instant I scroll",
* and the wheel is already rate-gated upstream. */
const BURST_WINDOW_MS = 250;
/** Start the auto-advance this far before the playing clip's end, so the
* next clip crossfades in while the tail is still sounding — continuous
* auto-play instead of end→silence→late fade-in. Sized for load latency
* (~50 ms) + the full crossfade; MEDIA_ENDED remains the backstop for clips
* that run shorter than their 30 s nominal duration. */
const EARLY_ADVANCE_MS = CROSSFADE_MS + 60;
export function createPreviewService({
resolver,
// The service CONSTRUCTS the transport, binding it to its own dispatch —
// the transport's events (MEDIA_PLAYING, MEDIA_ENDED, …) flow through the
// same generation-guarded reducer as everything else. Passing an already-
// built transport is how the original draft silently lost that wiring.
transportFactory,
focus, // { coordinator } from focusBridge, or a test double
settleMs = 150,
scheduler = defaultScheduler,
monotonicNow = () => (globalThis.performance ?? Date).now(),
}) {
let state = null;
let listeners = new Set();
let settleTimer = null;
let focusToken = null;
let focusWaiting = false;
// Deferred open crossfade: true from a granted open until the first
// preview clip is audible (or definitively won't be), at which point the
// main track's fade-out fires — synced to the preview's fade-in.
let pendingOpenSilence = false;
// Burst detection for the adaptive settle window.
let lastNavAt = -Infinity;
// One early auto-advance per generation, however many position events
// cross the threshold.
let lastEarlyAdvanceGen = -1;
// sessionKey -> { lease, receiptAt } — the two-neighbour prefetch (≤2).
let prefetched = new Map();
const emit = () => {
for (const l of listeners) l(state, { focusWaiting });
};
const dispatch = (action) => {
if (!state) return;
const before = state;
state = reduce(state, action);
if (state !== before) {
afterChange(before);
emit();
}
};
const transport = transportFactory(dispatch);
// ------------------------------------------------------ media pipeline --
function afterChange(before) {
if (!state.manifest) return;
const generationMoved = state.requestGeneration !== before.requestGeneration;
if (generationMoved && state.phase === PHASE.RESOLVING_MEDIA) {
scheduleSettle();
}
// Deferred open crossfade: fade the main track out the instant the
// opening preview clip becomes audible (PLAYING) — so the ramps
// overlap — or the moment it's clear it never will (ITEM_FAILED),
// matching the old take-audio-on-open behaviour on failure.
if (pendingOpenSilence && (state.phase === PHASE.PLAYING || state.phase === PHASE.ITEM_FAILED)) {
pendingOpenSilence = false;
focus.coordinator.beginMainSilence?.();
}
maybeScheduleEarlyAdvance();
}
/** Continuous auto-play: once the playing clip is within EARLY_ADVANCE_MS
* of its end and a next playable exists, step forward NOW — the step's
* crossfade then overlaps the tail instead of following the silence.
* Latched per generation; the dispatch is deferred a tick (via the
* scheduler) so a reducer transition never re-enters the reducer. */
function maybeScheduleEarlyAdvance() {
if (!state || state.phase !== PHASE.PLAYING || !state.desiredPlaying || state.muted) return;
const gen = state.requestGeneration;
if (gen === lastEarlyAdvanceGen) return;
const clip = state.clipDurationMs;
if (!clip || state.positionMs < clip - EARLY_ADVANCE_MS) return;
const playable = playableManifestIndices(state);
const cur = playableCursor(state);
if (cur == null || cur + 1 >= playable.length) return; // last clip ends naturally
lastEarlyAdvanceGen = gen;
const sessionId = state.sessionId;
scheduler.set(() => {
if (!state || state.sessionId !== sessionId || state.requestGeneration !== gen) return;
if (state.phase !== PHASE.PLAYING || !state.desiredPlaying || state.muted) return;
dispatch({ type: "STEP", sessionId, dir: 1 });
}, 0);
}
function scheduleSettle() {
if (settleTimer != null) scheduler.clear(settleTimer);
const sessionId = state.sessionId;
const generation = state.requestGeneration;
// Fade-capable transports (native) keep the outgoing clip SOUNDING
// through settle + resolve: the crossfade inside load() ramps it out
// against the incoming clip, which is the only way the two overlap.
// Fading here at t=0 was the 2026-07-23 gap bug — the outgoing was
// silent before the incoming even loaded. Transports without fades
// (fixture/tests) keep the original hard stop; either way superseded
// events carry a stale generation and land inert at the reducer.
if (typeof transport.fadeOut !== "function") void transport.stop();
// Adaptive settle: an isolated navigation fires the pipeline at once
// (audio the instant you scroll); only a BURST — steps landing within
// BURST_WINDOW_MS of each other, i.e. a held key — pays the
// coalescing window so intermediates never load.
const now = monotonicNow();
const delay = now - lastNavAt > BURST_WINDOW_MS ? 0 : settleMs;
lastNavAt = now;
settleTimer = scheduler.set(() => {
settleTimer = null;
if (!state || state.sessionId !== sessionId || state.requestGeneration !== generation) return;
void runPipeline(sessionId, generation);
}, delay);
}
async function runPipeline(sessionId, generation) {
const fresh = () =>
state && state.sessionId === sessionId && state.requestGeneration === generation;
if (!fresh() || state.activeItemKey == null) return;
const item = state.manifest.items[state.activeManifestIndex];
// On fade transports the OUTGOING clip is still sounding right now —
// scheduleSettle deliberately left it alone so load()'s crossfade can
// ramp it out against the incoming clip. These guards are the two
// cases where that handover never comes: the incoming load fails, or
// it overstays (slow un-prefetched resolve) — then the outgoing gets
// the standalone fade to silence instead of playing on under the
// wrong card. Both are no-ops for stop-at-settle transports.
let overstayTimer = null;
if (typeof transport.fadeOut === "function") {
overstayTimer = scheduler.set(() => {
overstayTimer = null;
if (fresh()) void transport.fadeOut(CROSSFADE_MS);
}, OUTGOING_OVERSTAY_MS);
}
const clearOverstay = () => {
if (overstayTimer != null) {
scheduler.clear(overstayTimer);
overstayTimer = null;
}
};
const retireOutgoing = () => {
clearOverstay();
if (typeof transport.fadeOut === "function") void transport.fadeOut(CROSSFADE_MS);
};
const source = item.sources[0];
if (!source) {
retireOutgoing();
dispatch({ type: "ITEM_FAILED", sessionId, generation, sessionKey: item.sessionKey, errorClass: "no_source" });
return;
}
dispatch({ type: "RESOLVE_STARTED", sessionId, generation, sessionKey: item.sessionKey });
// Prefetched lease still inside its safe window → skip the resolve.
let lease = null;
const pre = prefetched.get(item.sessionKey);
if (pre && monotonicNow() - pre.receiptAt < pre.lease.safeForMs * 0.8) {
lease = pre.lease;
}
prefetched.delete(item.sessionKey);
if (!lease) {
let result = await resolver.resolve(source, { generation });
if (!fresh()) return clearOverstay();
if (!result.ok && result.retryable) {
// The capped retry: exactly one cache-bypass re-mint per
// user intent, per the resolver contract. Never a loop.
result = await resolver.resolve(source, { generation, bypassCache: true });
if (!fresh()) return clearOverstay();
}
if (!result.ok) {
retireOutgoing();
dispatch({
type: "ITEM_FAILED",
sessionId,
generation,
sessionKey: item.sessionKey,
errorClass: result.failure,
availability: result.failure === "not_provided" ? "not_provided" : undefined,
});
return;
}
lease = result.lease;
}
dispatch({
type: "LEASE_READY",
sessionId,
generation,
sessionKey: item.sessionKey,
clipDurationMs: lease.clipDurationMs,
});
if (!fresh()) return clearOverstay();
// The load owns the handover from here (crossfade or hard swap).
clearOverstay();
const audible = state.desiredPlaying && focusToken != null && !state.muted;
// Session resume memory: a revisited clip picks up where it left
// off (recorded by the reducer on departure; 0 after it finished).
const resumeMs = state.itemRuntimeByKey[item.sessionKey]?.resumeMs ?? 0;
await transport.load(
lease,
{ sessionId, generation, sessionKey: item.sessionKey },
{ startPaused: !audible, crossfadeMs: audible ? CROSSFADE_MS : 0, startAtMs: resumeMs }
);
if (!fresh()) {
void transport.stop();
return;
}
if (resumeMs > 0) {
// Paint the scrubber at the resume point immediately; the
// transport's own position events take over within a tick.
dispatch({ type: "MEDIA_POSITION", sessionId, generation, positionMs: resumeMs });
}
if (audible) await transport.play();
prefetchNeighbors(sessionId, generation);
}
/** Resolve BOTH playable neighbours' leases in the background so a step
* in either direction usually skips the network entirely. Forward-only
* prefetch was the 2026-07-23 asymmetry bug: down-steps crossfaded in
* ~180 ms while up-steps sat through a full resolve. Bounded at the two
* neighbours of the current position — still leases only, no byte
* prefetch beyond what resolve itself stores. */
function prefetchNeighbors(sessionId, generation) {
if (!state || state.sessionId !== sessionId || state.requestGeneration !== generation) return;
const playable = playableManifestIndices(state);
const cur = playableCursor(state);
if (cur == null) return;
const wanted = [];
if (cur + 1 < playable.length) wanted.push(state.manifest.items[playable[cur + 1]]);
if (cur - 1 >= 0) wanted.push(state.manifest.items[playable[cur - 1]]);
// Prune anything that is no longer a neighbour — the map stays at ≤2.
const wantedKeys = new Set(wanted.map((i) => i.sessionKey));
for (const key of [...prefetched.keys()]) {
if (!wantedKeys.has(key)) prefetched.delete(key);
}
for (const item of wanted) {
if (prefetched.has(item.sessionKey) || !item.sources[0]) continue;
// A settled failure stays settled: background prefetch must not
// grow the pipeline's capped retry into a loop.
if (state.itemRuntimeByKey[item.sessionKey]?.errorClass) continue;
void resolver
.resolve(item.sources[0], { generation })
.then((r) => {
if (r.ok && state && state.sessionId === sessionId) {
prefetched.set(item.sessionKey, { lease: r.lease, receiptAt: monotonicNow() });
}
})
.catch(() => {});
}
}
// -------------------------------------------------------------- focus --
async function ensureFocus() {
if (focusToken) return true;
focusWaiting = true;
emit();
try {
const { granted, token } = await focus.coordinator.acquirePreview();
focusToken = granted ? token : null;
return granted;
} catch {
focusToken = null;
return false;
} finally {
focusWaiting = false;
emit();
}
}
// ----------------------------------------------------------- public ----
return {
subscribe(fn) {
listeners.add(fn);
if (state) fn(state, { focusWaiting });
return () => listeners.delete(fn);
},
getState: () => state,
isFocusWaiting: () => focusWaiting,
/** Open a session: focus first (take-audio-on-open), then manifest.
* If the acknowledgement fails, the session still opens — parked,
* with the transient wait surfaced — and Play retries acquisition. */
async open({ sessionId, manifest, startAtManifestIndex }) {
state = createInitialState(sessionId);
emit();
const granted = await ensureFocus();
// Arm the deferred main fade only on a granted open (a parked
// session leaves the main track playing). It fires from
// afterChange when the first clip is audible.
pendingOpenSilence = granted;
dispatch({ type: "MANIFEST_READY", sessionId, manifest, startAtManifestIndex });
if (!granted && state.phase !== PHASE.EMPTY) {
dispatch({ type: "SET_DESIRED_PLAYING", sessionId, value: false });
}
},
step: (dir) => dispatch({ type: "STEP", sessionId: state?.sessionId, dir }),
activateKey: (sessionKey) => dispatch({ type: "ACTIVATE_KEY", sessionId: state?.sessionId, sessionKey }),
replay: () => dispatch({ type: "REPLAY", sessionId: state?.sessionId }),
seek: (positionMs) => {
if (!state) return;
void transport.seek?.(positionMs);
dispatch({
type: "MEDIA_POSITION",
sessionId: state.sessionId,
generation: state.requestGeneration,
positionMs,
});
},
setMuted(value) {
if (!state) return;
dispatch({ type: "SET_MUTED", sessionId: state.sessionId, value });
if (value && state.phase === PHASE.PLAYING) void transport.pause();
},
/** Mirror the APP's volume (and mute, as 0) onto the preview
* transport — previews must never play louder than the player bar
* says. No-op for transports without a volume control (fixture). */
setVolume(v) {
void transport.setVolume?.(v);
},
async togglePlay() {
if (!state) return;
const sid = state.sessionId;
if (state.phase === PHASE.PLAYING) {
dispatch({ type: "SET_DESIRED_PLAYING", sessionId: sid, value: false });
await transport.pause();
return;
}
// Mid-transition (step still resolving/buffering) with listen
// intent: the button shows a pause glyph, so a click must PARK —
// drop the intent (the pipeline will load startPaused) and
// silence whatever is still sounding (the crossfade's outgoing).
if (
state.desiredPlaying &&
(state.phase === PHASE.RESOLVING_MEDIA || state.phase === PHASE.BUFFERING)
) {
dispatch({ type: "SET_DESIRED_PLAYING", sessionId: sid, value: false });
await transport.pause();
return;
}
if (state.phase === PHASE.ENDED) {
dispatch({ type: "REPLAY", sessionId: sid });
return;
}
dispatch({ type: "SET_DESIRED_PLAYING", sessionId: sid, value: true });
const granted = await ensureFocus();
if (!granted) return; // parked; wait state already surfaced
if (state.phase === PHASE.READY || state.phase === PHASE.PAUSED) {
await transport.play();
}
// RESOLVING/BUFFERING: the pipeline observes desiredPlaying when
// the lease lands; nothing else to do here.
},
/** Play full track: hand ownership to ordinary playback. The old
* main snapshot is intentionally discarded (never restored). */
async playFullTrack(startMainPlayback) {
if (!state) return;
await transport.stop();
focus.coordinator.handoffToMain();
focusToken = null;
dispatch({ type: "SET_DESIRED_PLAYING", sessionId: state.sessionId, value: false });
await startMainPlayback(state.manifest.items[state.activeManifestIndex]);
},
/** Close: terminal for the session; transport torn down; main
* restored only if we paused it and nothing changed since. On fade
* transports the close is a CROSSFADE: the preview rings down while
* the release's fade-resume brings the main track back up, and the
* teardown waits for the tail so dispose cannot cut it off. */
async close() {
if (!state) return { restored: false };
if (settleTimer != null) {
scheduler.clear(settleTimer);
settleTimer = null;
}
// Closing before the opening clip ever sounded: leave the deferred
// fade unfired — release() will find the main still playing and
// simply disarm it (no fade-resume dip).
pendingOpenSilence = false;
dispatch({ type: "CLOSE", sessionId: state.sessionId });
prefetched.clear();
const fades = typeof transport.fadeOut === "function";
if (fades) void transport.fadeOut(CROSSFADE_MS);
else await transport.stop();
const token = focusToken;
focusToken = null;
const restored = token ? focus.coordinator.release(token) : { restored: false };
if (fades) {
await new Promise((resolve) => scheduler.set(resolve, CROSSFADE_MS + 80));
}
await transport.dispose();
return restored;
},
};
}