forked from Keelrleek/SoulStream
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:
1 parent
5e877c0b0a
commit
793f78370e
27 files changed
+2834
-178
No files matched your search
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user