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.
This commit is contained in:
KeelrLeek committed 2026-07-23 16:05:14 -05:00
1 parent 5e877c0b0a
commit 793f78370e
27 files changed
+2834 -178

No files matched your search

+33 -2
View File
@@ -95,13 +95,29 @@ function withRuntime(state, sessionKey, 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,
@@ -178,8 +194,15 @@ export function reduce(state, action) {
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(state, state.activeManifestIndex, { keepDesired: true }),
...activate(cleared, cleared.activeManifestIndex, { keepDesired: true }),
desiredPlaying: true,
});
}
@@ -237,7 +260,15 @@ export function reduce(state, action) {
if (cur != null && cur + 1 < playable.length) {
return activate(state, playable[cur + 1], { keepDesired: true });
}
return Object.freeze({ ...state, phase: PHASE.ENDED, desiredPlaying: false });
// 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;