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
+173
-33
@@ -2,7 +2,7 @@
|
||||
//
|
||||
// The reducer decides WHAT happens; this service decides WHEN: the settle
|
||||
// window after navigation, the resolve→load→play pipeline, the single
|
||||
// cache-bypass retry, one direction-aware lease prefetch, and the focus
|
||||
// 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.
|
||||
@@ -20,6 +20,33 @@ const defaultScheduler = {
|
||||
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 —
|
||||
@@ -37,7 +64,12 @@ export function createPreviewService({
|
||||
let settleTimer = null;
|
||||
let focusToken = null;
|
||||
let focusWaiting = false;
|
||||
// sessionKey -> { lease, receiptAt } — the one direction-aware prefetch.
|
||||
// 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 = () => {
|
||||
@@ -62,20 +94,56 @@ export function createPreviewService({
|
||||
if (generationMoved && state.phase === PHASE.RESOLVING_MEDIA) {
|
||||
scheduleSettle();
|
||||
}
|
||||
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;
|
||||
// Stop the outgoing clip immediately — silence between cards is
|
||||
// correct; audio under the wrong artwork never is.
|
||||
void transport.stop();
|
||||
// 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);
|
||||
}, settleMs);
|
||||
}, delay);
|
||||
}
|
||||
|
||||
async function runPipeline(sessionId, generation) {
|
||||
@@ -83,8 +151,35 @@ export function createPreviewService({
|
||||
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;
|
||||
}
|
||||
@@ -100,14 +195,15 @@ export function createPreviewService({
|
||||
|
||||
if (!lease) {
|
||||
let result = await resolver.resolve(source, { generation });
|
||||
if (!fresh()) return;
|
||||
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;
|
||||
if (!fresh()) return clearOverstay();
|
||||
}
|
||||
if (!result.ok) {
|
||||
retireOutgoing();
|
||||
dispatch({
|
||||
type: "ITEM_FAILED",
|
||||
sessionId,
|
||||
@@ -128,42 +224,66 @@ export function createPreviewService({
|
||||
sessionKey: item.sessionKey,
|
||||
clipDurationMs: lease.clipDurationMs,
|
||||
});
|
||||
if (!fresh()) return;
|
||||
if (!fresh()) return clearOverstay();
|
||||
|
||||
// The load owns the handover from here (crossfade or hard swap).
|
||||
clearOverstay();
|
||||
const audible = state.desiredPlaying && focusToken != null && !state.muted;
|
||||
await transport.load(lease, { sessionId, generation, sessionKey: item.sessionKey }, { startPaused: !audible });
|
||||
// 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();
|
||||
|
||||
prefetchNeighbor(sessionId, generation);
|
||||
prefetchNeighbors(sessionId, generation);
|
||||
}
|
||||
|
||||
/** Resolve the NEXT playable item's lease in the background so stepping
|
||||
* forward usually skips the network entirely. One item, forward only —
|
||||
* the bounded mechanism the plan allows without byte prefetch. */
|
||||
function prefetchNeighbor(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 || cur + 1 >= playable.length) return;
|
||||
const next = state.manifest.items[playable[cur + 1]];
|
||||
if (prefetched.has(next.sessionKey) || !next.sources[0]) return;
|
||||
void resolver
|
||||
.resolve(next.sources[0], { generation })
|
||||
.then((r) => {
|
||||
if (r.ok && state && state.sessionId === sessionId) {
|
||||
prefetched.set(next.sessionKey, { lease: r.lease, receiptAt: monotonicNow() });
|
||||
// bounded: keep at most one prefetched lease around
|
||||
if (prefetched.size > 1) {
|
||||
const oldest = prefetched.keys().next().value;
|
||||
if (oldest !== next.sessionKey) prefetched.delete(oldest);
|
||||
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(() => {});
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- focus --
|
||||
@@ -236,6 +356,18 @@ export function createPreviewService({
|
||||
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;
|
||||
@@ -262,7 +394,10 @@ export function createPreviewService({
|
||||
},
|
||||
|
||||
/** Close: terminal for the session; transport torn down; main
|
||||
* restored only if we paused it and nothing changed since. */
|
||||
* 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) {
|
||||
@@ -271,11 +406,16 @@ export function createPreviewService({
|
||||
}
|
||||
dispatch({ type: "CLOSE", sessionId: state.sessionId });
|
||||
prefetched.clear();
|
||||
await transport.stop();
|
||||
await transport.dispose();
|
||||
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;
|
||||
},
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user