fix: defer the open crossfade so main no longer fades into a gap
Opening the preview faded the main track out at focus-acquire time, but the first preview clip only becomes audible after resolve/load/decode — so on the way IN the 340 ms main fade completed into silence before the preview arrived (a gap the user heard; the exit was already fine). The main fade is now deferred: focusBridge.requestSilence only arms the commitment and grants; coordinator.beginMainSilence performs the actual fade-pause, and the service fires it from afterChange on the opening session's first audible clip (or ITEM_FAILED). Main-cos-out then overlaps preview-sin-in — a true cross. resumeMain skips the fade-resume when the fade never fired (close before audible), and beginMainSilence re-checks the main is sounding before fading. The focus coordinator's acquisition logic and tests are untouched. Verified live: preview audible at 275 ms, main played until then, faded ~343 ms, paused at 618 ms and held frozen; close resumed from the exact position in 15 ms.
This commit is contained in:
1 parent
691fdcdc9a
commit
abc959ca2f
3 files changed
+88
-19
No files matched your search
@@ -9,17 +9,22 @@
|
||||
// replaces "ack.generation === snapshot's native generation" with an
|
||||
// equivalent renderer-side invariant:
|
||||
//
|
||||
// granted ⇔ the main fade-to-silence is RUNNING (see requestSilence —
|
||||
// since the 2026-07-23 open-crossfade, silence is a ramp the
|
||||
// preview deliberately overlaps, not an acknowledged fact)
|
||||
// granted ⇔ the main-silence commitment is armed (see requestSilence —
|
||||
// since the 2026-07-23 open-crossfade the audible fade is
|
||||
// DEFERRED, not run at acquire time)
|
||||
// ∧ no renderer PLAYBACK generation change during negotiation
|
||||
// (a new Buffering = a new track start = a new generation)
|
||||
// ∧ no revision-gateway change during negotiation
|
||||
//
|
||||
// 2. The open/close crossfades trade the hard "silence before preview
|
||||
// audio" gate for a bounded overlap window (CROSSFADE_MS); the backend
|
||||
// fade thread still ends in pause + desired-paused latch, and its
|
||||
// failure mode is silence, never double audio.
|
||||
// 2. The open crossfade defers the main fade-out until the FIRST preview
|
||||
// clip is actually audible (the service calls beginMainSilence on the
|
||||
// opening session's first MEDIA_PLAYING). Firing it at acquire time —
|
||||
// before the clip had resolved/loaded/decoded — faded the main track
|
||||
// into silence hundreds of ms ahead of the preview, which is the gap the
|
||||
// user heard on the way IN. Deferring makes main-cos-out and preview-
|
||||
// sin-in run together: a real cross. The backend fade thread still ends
|
||||
// in pause + desired-paused latch; failure mode is silence, never double
|
||||
// audio.
|
||||
//
|
||||
// Every native source change reaches the renderer as an event that bumps the
|
||||
// gateway (wired in state.svelte.js), so a native-side surprise invalidates
|
||||
@@ -42,6 +47,13 @@ import { CROSSFADE_MS } from "./service.js";
|
||||
export function createProductionFocus({ readPlayer, readPlaybackGeneration, resumeMain }) {
|
||||
const gateway = createRevisionGateway();
|
||||
|
||||
// Armed by requestSilence when acquire finds the main audible; FIRED by
|
||||
// beginMainSilence once the first preview clip is audible (deferred open
|
||||
// crossfade). While armed-but-unfired the main track is still playing
|
||||
// normally — so a close before the preview sounds must NOT fade-resume
|
||||
// it (that would dip an already-playing track); it just disarms.
|
||||
let mainSilenceArmed = false;
|
||||
|
||||
const coordinator = createFocusCoordinator({
|
||||
gateway,
|
||||
mainStatus: () => {
|
||||
@@ -58,17 +70,14 @@ export function createProductionFocus({ readPlayer, readPlaybackGeneration, resu
|
||||
};
|
||||
},
|
||||
requestSilence: async (generationAtSnapshot) => {
|
||||
// Open crossfade (user decision 2026-07-23): the main player
|
||||
// FADES to silence over the session crossfade instead of hard-
|
||||
// pausing, and this resolves as soon as the backend fade thread
|
||||
// is running — the first preview clip ramps in OVER the fading
|
||||
// main track. This deliberately relaxes the Phase 0.4 rule
|
||||
// ("acknowledged silence before any preview audio"): the
|
||||
// overlap is the feature. The pause + desired-paused latch land
|
||||
// at the fade's end inside the backend thread; its failure mode
|
||||
// is silence (volume left at 0), never double audio.
|
||||
const p = readPlayer();
|
||||
await api.playerFadePauseAcked(CROSSFADE_MS, p.muted ? 0 : (p.volume ?? 0.7));
|
||||
// DO NOT fade here. Arm the commitment and grant; the audible
|
||||
// main fade-out is deferred to beginMainSilence, called by the
|
||||
// service when the first preview clip actually sounds, so the two
|
||||
// ramps overlap instead of the main fading into a gap on open.
|
||||
// The generation-bound ack is synthesised renderer-side (the
|
||||
// backend fade call, when it eventually runs, carries no
|
||||
// generation of its own — nativeGeneration stays null).
|
||||
mainSilenceArmed = true;
|
||||
const generationNow = readPlaybackGeneration();
|
||||
return {
|
||||
// Applied only if no new main track started mid-negotiation;
|
||||
@@ -82,10 +91,29 @@ export function createProductionFocus({ readPlayer, readPlaybackGeneration, resu
|
||||
};
|
||||
},
|
||||
resumeMain: (snapshot, opts) => {
|
||||
if (mainSilenceArmed) {
|
||||
// The fade never fired — main is still playing normally, so
|
||||
// there is nothing to resume; just disarm.
|
||||
mainSilenceArmed = false;
|
||||
return;
|
||||
}
|
||||
gateway.userPlay(opts); // focus-internal: bumps nothing
|
||||
resumeMain(snapshot);
|
||||
},
|
||||
});
|
||||
|
||||
// Deferred open crossfade: fade the main track out NOW, synchronised with
|
||||
// the preview's fade-in. Idempotent, and only fades if the main is
|
||||
// actually sounding (guards a commitment that leaked past a handoff or an
|
||||
// idle-main open). Fresh volume read at fire time.
|
||||
coordinator.beginMainSilence = () => {
|
||||
if (!mainSilenceArmed) return;
|
||||
mainSilenceArmed = false;
|
||||
const p = readPlayer();
|
||||
const audible = p.status === "playing" || p.status === "buffering";
|
||||
if (!audible) return;
|
||||
void api.playerFadePauseAcked(CROSSFADE_MS, p.muted ? 0 : (p.volume ?? 0.7));
|
||||
};
|
||||
|
||||
return { coordinator, gateway };
|
||||
}
|
||||
@@ -64,6 +64,10 @@ export function createPreviewService({
|
||||
let settleTimer = null;
|
||||
let focusToken = null;
|
||||
let focusWaiting = false;
|
||||
// Deferred open crossfade: true from a granted open until the first
|
||||
// preview clip is audible (or definitively won't be), at which point the
|
||||
// main track's fade-out fires — synced to the preview's fade-in.
|
||||
let pendingOpenSilence = false;
|
||||
// Burst detection for the adaptive settle window.
|
||||
let lastNavAt = -Infinity;
|
||||
// One early auto-advance per generation, however many position events
|
||||
@@ -94,6 +98,14 @@ export function createPreviewService({
|
||||
if (generationMoved && state.phase === PHASE.RESOLVING_MEDIA) {
|
||||
scheduleSettle();
|
||||
}
|
||||
// Deferred open crossfade: fade the main track out the instant the
|
||||
// opening preview clip becomes audible (PLAYING) — so the ramps
|
||||
// overlap — or the moment it's clear it never will (ITEM_FAILED),
|
||||
// matching the old take-audio-on-open behaviour on failure.
|
||||
if (pendingOpenSilence && (state.phase === PHASE.PLAYING || state.phase === PHASE.ITEM_FAILED)) {
|
||||
pendingOpenSilence = false;
|
||||
focus.coordinator.beginMainSilence?.();
|
||||
}
|
||||
maybeScheduleEarlyAdvance();
|
||||
}
|
||||
|
||||
@@ -323,6 +335,10 @@ export function createPreviewService({
|
||||
state = createInitialState(sessionId);
|
||||
emit();
|
||||
const granted = await ensureFocus();
|
||||
// Arm the deferred main fade only on a granted open (a parked
|
||||
// session leaves the main track playing). It fires from
|
||||
// afterChange when the first clip is audible.
|
||||
pendingOpenSilence = granted;
|
||||
dispatch({ type: "MANIFEST_READY", sessionId, manifest, startAtManifestIndex });
|
||||
if (!granted && state.phase !== PHASE.EMPTY) {
|
||||
dispatch({ type: "SET_DESIRED_PLAYING", sessionId, value: false });
|
||||
@@ -411,6 +427,10 @@ export function createPreviewService({
|
||||
scheduler.clear(settleTimer);
|
||||
settleTimer = null;
|
||||
}
|
||||
// Closing before the opening clip ever sounded: leave the deferred
|
||||
// fade unfired — release() will find the main still playing and
|
||||
// simply disarm it (no fade-resume dip).
|
||||
pendingOpenSilence = false;
|
||||
dispatch({ type: "CLOSE", sessionId: state.sessionId });
|
||||
prefetched.clear();
|
||||
const fades = typeof transport.fadeOut === "function";
|
||||
|
||||
@@ -99,7 +99,7 @@ function makeTransport() {
|
||||
}
|
||||
|
||||
function makeFocus({ grant = true, pausedByPreview = true } = {}) {
|
||||
const calls = { acquire: 0, release: 0, handoff: 0 };
|
||||
const calls = { acquire: 0, release: 0, handoff: 0, beginMainSilence: 0 };
|
||||
let live = null;
|
||||
return {
|
||||
calls,
|
||||
@@ -111,6 +111,9 @@ function makeFocus({ grant = true, pausedByPreview = true } = {}) {
|
||||
live = { id: `t${calls.acquire}`, pausedByPreview };
|
||||
return { granted: true, token: live };
|
||||
},
|
||||
beginMainSilence() {
|
||||
calls.beginMainSilence += 1;
|
||||
},
|
||||
release(token) {
|
||||
calls.release += 1;
|
||||
const ok = token === live;
|
||||
@@ -161,6 +164,24 @@ test("open: focus, settle, resolve, load audible, play", async () => {
|
||||
assert.ok(transport.log.some((e) => e[0] === "play"));
|
||||
});
|
||||
|
||||
test("open crossfade: main is NOT silenced until the first preview clip is audible", async () => {
|
||||
const { service, scheduler, focus, manifest } = makeService();
|
||||
await service.open({ sessionId: SID, manifest });
|
||||
// Focus is granted, but the clip hasn't resolved/played — the main track
|
||||
// must still be sounding (the whole point of the deferred fade).
|
||||
assert.equal(focus.calls.beginMainSilence, 0, "no main fade through resolve/load");
|
||||
await scheduler.flush(); // resolve → load → play → MEDIA_PLAYING
|
||||
assert.equal(service.getState().phase, PHASE.PLAYING);
|
||||
assert.equal(focus.calls.beginMainSilence, 1, "main fades exactly when the preview is audible");
|
||||
});
|
||||
|
||||
test("open crossfade: a parked (denied) open never fades the main track", async () => {
|
||||
const { service, scheduler, focus, manifest } = makeService({ grant: false });
|
||||
await service.open({ sessionId: SID, manifest });
|
||||
await scheduler.flush();
|
||||
assert.equal(focus.calls.beginMainSilence, 0, "parked preview leaves the main playing");
|
||||
});
|
||||
|
||||
test("open with focus denied: session opens PARKED, wait state surfaced", async () => {
|
||||
const { service, scheduler, transport, manifest } = makeService({ grant: false });
|
||||
await service.open({ sessionId: SID, manifest });
|
||||
|
||||
Reference in new issue
Block a user