forked from Keelrleek/SoulStream
Preview modal audio, five rounds: - True track-change crossfade: scheduleSettle no longer silences the outgoing clip at t=0; preview_load's equal-power fade owns both ramps, gated on the incoming voice's first Playing event (audible_marks), with failure/overstay fallbacks. CROSSFADE_MS = 340 — locked to the reel's card-slide so audio and visuals land together. - Two-neighbour lease prefetch + a 15-min on-disk clip byte-cache (recent_clips): steps in BOTH directions crossfade in ~15 ms; revisits never re-download. - Adaptive settle (isolated steps fire immediately, bursts coalesce) and anticipatory auto-advance (step at clip end minus 400 ms) so auto-play crossfades over the tail instead of gapping. - Session resume memory: revisited tracks continue where they left off (decoder-level start offset via play_local_from/resume_at); finished tracks restart from the top. - Open/close crossfades with the MAIN player: fade-pause with the desired-paused latch landing at ramp end, fade-resume from the paused position; MAIN_FADE_GEN guards; focus handshake relaxation documented in focusBridge. Volume normalization (Settings > Playback, default on): - Dependency-free BS.1770/EBU R128 meter in the player crate (K-weighting, 400 ms gated blocks, incremental) + analyzer that reads the same growing file playback streams from — first plays are measured at download speed. - Gain layered under user volume; audible start GATED on a preliminary measurement (10 s of audio or 800 ms budget) so the level is right from the first sample and never changes mid-track; prelim boosts capped 3 dB, peak-aware clipping guard, persistent loudness-cache.json. - Gapless boundaries get their own sample-accurate gain via SetNextNormGainDb, fed by preload analysis. Debug overlay (Ctrl+Shift+D): process-tree CPU / PDH GPU / working-set + private-commit RAM / network rates / FPS / current-track download progress read from the backend's own StreamProgress / live normalization state / preview session phase. Polls only while visible. 131 JS + player + app tests green; every path verified live over CDP.
309 lines
13 KiB
JavaScript
309 lines
13 KiB
JavaScript
// Pure preview-session reducer — design §12 runtime state, §9 navigation
|
||
// invariants. No timers, no I/O, no Date.now(): every action carries what it
|
||
// needs, so the whole machine replays deterministically under node:test.
|
||
//
|
||
// THE one rule everything else hangs off: any action produced by async work
|
||
// (resolver, transport) carries {sessionId, generation}, and the reducer
|
||
// drops it unless BOTH match the current state. A late lease from generation
|
||
// 3 can never start audio under the cover generation 4 is showing.
|
||
|
||
import { AVAILABILITY } from "./contracts.js";
|
||
|
||
export const PHASE = Object.freeze({
|
||
RESOLVING_MANIFEST: "resolving_manifest",
|
||
READY: "ready",
|
||
RESOLVING_MEDIA: "resolving_media",
|
||
BUFFERING: "buffering",
|
||
PLAYING: "playing",
|
||
PAUSED: "paused",
|
||
ENDED: "ended",
|
||
ITEM_FAILED: "item_failed",
|
||
EMPTY: "empty",
|
||
CLOSING: "closing",
|
||
});
|
||
|
||
export function createInitialState(sessionId) {
|
||
if (!sessionId) throw new Error("sessionId required");
|
||
return Object.freeze({
|
||
sessionId,
|
||
phase: PHASE.RESOLVING_MANIFEST,
|
||
manifest: null,
|
||
itemRuntimeByKey: Object.freeze({}),
|
||
activeItemKey: null,
|
||
activeManifestIndex: null,
|
||
requestGeneration: 0,
|
||
desiredPlaying: false,
|
||
muted: false,
|
||
positionMs: 0,
|
||
clipDurationMs: undefined,
|
||
focusToken: null,
|
||
errorClass: undefined,
|
||
});
|
||
}
|
||
|
||
// ------------------------------------------------------------ derivations --
|
||
|
||
/** Effective availability: runtime overrides the manifest's initial value. */
|
||
export function effectiveAvailability(state, item) {
|
||
return state.itemRuntimeByKey[item.sessionKey]?.availability ?? item.initialAvailability;
|
||
}
|
||
|
||
/** Manifest indices whose items are currently playable, in manifest order.
|
||
* Derived every time — never stored, so it can't drift (design §9). */
|
||
export function playableManifestIndices(state) {
|
||
if (!state.manifest) return [];
|
||
const out = [];
|
||
state.manifest.items.forEach((item, i) => {
|
||
if (effectiveAvailability(state, item) === AVAILABILITY.AVAILABLE) out.push(i);
|
||
});
|
||
return out;
|
||
}
|
||
|
||
/** Position of the active item inside the playable list, or null when the
|
||
* active item is not currently eligible (e.g. it failed and is being shown
|
||
* in its error state). The active item is identified by sessionKey + index —
|
||
* never by this cursor (design §9: availability changes recompute the cursor
|
||
* AROUND the stable item; they never move the item). */
|
||
export function playableCursor(state) {
|
||
if (state.activeManifestIndex == null) return null;
|
||
const idx = playableManifestIndices(state).indexOf(state.activeManifestIndex);
|
||
return idx === -1 ? null : idx;
|
||
}
|
||
|
||
function itemAt(state, manifestIndex) {
|
||
return state.manifest?.items[manifestIndex] ?? null;
|
||
}
|
||
|
||
// -------------------------------------------------------------- reducer ----
|
||
|
||
function guard(state, action, { needGeneration }) {
|
||
if (action.sessionId !== state.sessionId) return false;
|
||
if (state.phase === PHASE.CLOSING) return false; // terminal: nothing lands
|
||
if (needGeneration && action.generation !== state.requestGeneration) return false;
|
||
return true;
|
||
}
|
||
|
||
function withRuntime(state, sessionKey, patch) {
|
||
const prev = state.itemRuntimeByKey[sessionKey] ?? {
|
||
availability: undefined,
|
||
mediaPhase: "idle",
|
||
errorClass: undefined,
|
||
};
|
||
return Object.freeze({
|
||
...state.itemRuntimeByKey,
|
||
[sessionKey]: Object.freeze({ ...prev, ...patch }),
|
||
});
|
||
}
|
||
|
||
/** Leaving a clip with less than this remaining counts as FINISHING it — a
|
||
* revisit starts from the top. Sized just past the service's anticipatory
|
||
* auto-advance point (clip end − ~400 ms), so an auto-advanced track always
|
||
* restarts while a mid-clip departure always resumes. */
|
||
const RESUME_END_GUARD_MS = 500;
|
||
|
||
/** Activate the item at `manifestIndex`, bumping the generation — the
|
||
* common core of first-pick, step, click and auto-advance. */
|
||
function activate(state, manifestIndex, { keepDesired }) {
|
||
const item = itemAt(state, manifestIndex);
|
||
if (!item) return state;
|
||
// Remember where the OUTGOING clip stopped so a revisit can pick it back
|
||
// up. positionMs === 0 records nothing — a burst step that only passed
|
||
// through must not wipe a position remembered from a real earlier visit.
|
||
let runtime = state.itemRuntimeByKey;
|
||
if (state.activeItemKey && state.activeItemKey !== item.sessionKey && state.positionMs > 0) {
|
||
const clip = state.clipDurationMs ?? 30_000;
|
||
const resumeMs = clip - state.positionMs > RESUME_END_GUARD_MS ? state.positionMs : 0;
|
||
runtime = withRuntime(state, state.activeItemKey, { resumeMs });
|
||
}
|
||
return Object.freeze({
|
||
...state,
|
||
itemRuntimeByKey: runtime,
|
||
phase: PHASE.RESOLVING_MEDIA,
|
||
activeItemKey: item.sessionKey,
|
||
activeManifestIndex: manifestIndex,
|
||
requestGeneration: state.requestGeneration + 1,
|
||
desiredPlaying: keepDesired ? state.desiredPlaying : state.desiredPlaying,
|
||
positionMs: 0,
|
||
clipDurationMs: undefined,
|
||
errorClass: undefined,
|
||
});
|
||
}
|
||
|
||
export function reduce(state, action) {
|
||
switch (action.type) {
|
||
// ---- session lifecycle ------------------------------------------
|
||
case "MANIFEST_READY": {
|
||
if (!guard(state, action, { needGeneration: false })) return state;
|
||
const next = Object.freeze({ ...state, manifest: action.manifest });
|
||
const playable = playableManifestIndices(next);
|
||
if (playable.length === 0) {
|
||
return Object.freeze({ ...next, phase: PHASE.EMPTY });
|
||
}
|
||
// Opening takes audio (decision 2026-07-22): the first playable
|
||
// item becomes active with listen intent already set; the focus
|
||
// coordinator upstream decides when sound may actually start.
|
||
return activate(
|
||
Object.freeze({ ...next, desiredPlaying: true }),
|
||
action.startAtManifestIndex != null && playable.includes(action.startAtManifestIndex)
|
||
? action.startAtManifestIndex
|
||
: playable[0],
|
||
{ keepDesired: true }
|
||
);
|
||
}
|
||
case "CLOSE": {
|
||
if (action.sessionId !== state.sessionId) return state;
|
||
return Object.freeze({ ...state, phase: PHASE.CLOSING, desiredPlaying: false, focusToken: null });
|
||
}
|
||
|
||
// ---- navigation (user intent, current-generation by definition) --
|
||
case "STEP": {
|
||
if (!guard(state, action, { needGeneration: false })) return state;
|
||
const playable = playableManifestIndices(state);
|
||
if (playable.length === 0) return state;
|
||
const cur = playableCursor(state);
|
||
let target;
|
||
if (cur != null) {
|
||
target = cur + action.dir;
|
||
} else {
|
||
// Active item isn't eligible (failed): move to the next
|
||
// eligible MANIFEST ordinal in the step direction (design §9).
|
||
const from = state.activeManifestIndex ?? -1;
|
||
target =
|
||
action.dir > 0
|
||
? playable.findIndex((i) => i > from)
|
||
: (() => {
|
||
let last = -1;
|
||
playable.forEach((i, k) => {
|
||
if (i < from) last = k;
|
||
});
|
||
return last;
|
||
})();
|
||
}
|
||
if (target < 0 || target >= playable.length) return state; // no wrap
|
||
return activate(state, playable[target], { keepDesired: true });
|
||
}
|
||
case "ACTIVATE_KEY": {
|
||
if (!guard(state, action, { needGeneration: false })) return state;
|
||
const idx = state.manifest?.items.findIndex((it) => it.sessionKey === action.sessionKey) ?? -1;
|
||
if (idx === -1) return state;
|
||
const item = state.manifest.items[idx];
|
||
if (effectiveAvailability(state, item) !== AVAILABILITY.AVAILABLE) return state;
|
||
if (idx === state.activeManifestIndex) return state;
|
||
return activate(state, idx, { keepDesired: true });
|
||
}
|
||
case "REPLAY": {
|
||
if (!guard(state, action, { needGeneration: false })) return state;
|
||
if (state.activeManifestIndex == null) return state;
|
||
// Replay means "from the top" — drop any remembered position.
|
||
const cleared = state.activeItemKey
|
||
? Object.freeze({
|
||
...state,
|
||
itemRuntimeByKey: withRuntime(state, state.activeItemKey, { resumeMs: 0 }),
|
||
})
|
||
: state;
|
||
return Object.freeze({
|
||
...activate(cleared, cleared.activeManifestIndex, { keepDesired: true }),
|
||
desiredPlaying: true,
|
||
});
|
||
}
|
||
case "SET_DESIRED_PLAYING": {
|
||
if (!guard(state, action, { needGeneration: false })) return state;
|
||
return Object.freeze({ ...state, desiredPlaying: !!action.value });
|
||
}
|
||
case "SET_MUTED": {
|
||
if (!guard(state, action, { needGeneration: false })) return state;
|
||
return Object.freeze({ ...state, muted: !!action.value });
|
||
}
|
||
case "FOCUS_SET": {
|
||
if (!guard(state, action, { needGeneration: false })) return state;
|
||
return Object.freeze({ ...state, focusToken: action.token ?? null });
|
||
}
|
||
|
||
// ---- async completions (generation-gated) ------------------------
|
||
case "RESOLVE_STARTED": {
|
||
if (!guard(state, action, { needGeneration: true })) return state;
|
||
if (action.sessionKey !== state.activeItemKey) return state;
|
||
return Object.freeze({
|
||
...state,
|
||
itemRuntimeByKey: withRuntime(state, action.sessionKey, { mediaPhase: "resolving" }),
|
||
});
|
||
}
|
||
case "LEASE_READY": {
|
||
if (!guard(state, action, { needGeneration: true })) return state;
|
||
if (action.sessionKey !== state.activeItemKey) return state;
|
||
return Object.freeze({
|
||
...state,
|
||
phase: state.desiredPlaying ? PHASE.BUFFERING : PHASE.READY,
|
||
clipDurationMs: action.clipDurationMs ?? state.clipDurationMs,
|
||
itemRuntimeByKey: withRuntime(state, action.sessionKey, { mediaPhase: "ready" }),
|
||
});
|
||
}
|
||
case "MEDIA_PLAYING": {
|
||
if (!guard(state, action, { needGeneration: true })) return state;
|
||
return Object.freeze({ ...state, phase: PHASE.PLAYING });
|
||
}
|
||
case "MEDIA_PAUSED": {
|
||
if (!guard(state, action, { needGeneration: true })) return state;
|
||
return Object.freeze({ ...state, phase: PHASE.PAUSED });
|
||
}
|
||
case "MEDIA_POSITION": {
|
||
if (!guard(state, action, { needGeneration: true })) return state;
|
||
return Object.freeze({ ...state, positionMs: action.positionMs });
|
||
}
|
||
case "MEDIA_ENDED": {
|
||
if (!guard(state, action, { needGeneration: true })) return state;
|
||
// Auto-advance (decision 2026-07-22): roll into the next playable
|
||
// item under the SAME generation rules — activate() bumps, so any
|
||
// straggler event from the finished clip is stale by construction.
|
||
const playable = playableManifestIndices(state);
|
||
const cur = playableCursor(state);
|
||
if (cur != null && cur + 1 < playable.length) {
|
||
return activate(state, playable[cur + 1], { keepDesired: true });
|
||
}
|
||
// The last clip finished: any revisit or replay starts fresh.
|
||
return Object.freeze({
|
||
...state,
|
||
phase: PHASE.ENDED,
|
||
desiredPlaying: false,
|
||
itemRuntimeByKey: state.activeItemKey
|
||
? withRuntime(state, state.activeItemKey, { resumeMs: 0 })
|
||
: state.itemRuntimeByKey,
|
||
});
|
||
}
|
||
case "ITEM_FAILED": {
|
||
if (!guard(state, action, { needGeneration: true })) return state;
|
||
const runtime = withRuntime(state, action.sessionKey, {
|
||
availability: action.availability ?? AVAILABILITY.TEMP_FAILED,
|
||
mediaPhase: "failed",
|
||
errorClass: action.errorClass,
|
||
});
|
||
const isActive = action.sessionKey === state.activeItemKey;
|
||
return Object.freeze({
|
||
...state,
|
||
itemRuntimeByKey: runtime,
|
||
// A failed ACTIVE item stays visible in its error state
|
||
// (design §9) — no auto-skip, the user retries or steps.
|
||
phase: isActive ? PHASE.ITEM_FAILED : state.phase,
|
||
errorClass: isActive ? action.errorClass : state.errorClass,
|
||
});
|
||
}
|
||
|
||
// ---- runtime availability (metadata, NOT generation-gated) -------
|
||
case "AVAILABILITY_CHANGED": {
|
||
if (!guard(state, action, { needGeneration: false })) return state;
|
||
// Only itemRuntimeByKey changes: a newly available EARLIER item
|
||
// must never move the current card (design §9) — and it can't,
|
||
// because the active item is keyed, not cursor-positioned.
|
||
return Object.freeze({
|
||
...state,
|
||
itemRuntimeByKey: withRuntime(state, action.sessionKey, {
|
||
availability: action.availability,
|
||
}),
|
||
});
|
||
}
|
||
|
||
default:
|
||
return state;
|
||
}
|
||
}
|