Files
SoulStream/src/lib/preview/session.js
T
KeelrLeek 793f78370e feat: crossfade preview suite, loudness normalization, debug overlay
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.
2026-07-23 16:05:14 -05:00

309 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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;
}
}