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
|
// replaces "ack.generation === snapshot's native generation" with an
|
||||||
// equivalent renderer-side invariant:
|
// equivalent renderer-side invariant:
|
||||||
//
|
//
|
||||||
// granted ⇔ the main fade-to-silence is RUNNING (see requestSilence —
|
// granted ⇔ the main-silence commitment is armed (see requestSilence —
|
||||||
// since the 2026-07-23 open-crossfade, silence is a ramp the
|
// since the 2026-07-23 open-crossfade the audible fade is
|
||||||
// preview deliberately overlaps, not an acknowledged fact)
|
// DEFERRED, not run at acquire time)
|
||||||
// ∧ no renderer PLAYBACK generation change during negotiation
|
// ∧ no renderer PLAYBACK generation change during negotiation
|
||||||
// (a new Buffering = a new track start = a new generation)
|
// (a new Buffering = a new track start = a new generation)
|
||||||
// ∧ no revision-gateway change during negotiation
|
// ∧ no revision-gateway change during negotiation
|
||||||
//
|
//
|
||||||
// 2. The open/close crossfades trade the hard "silence before preview
|
// 2. The open crossfade defers the main fade-out until the FIRST preview
|
||||||
// audio" gate for a bounded overlap window (CROSSFADE_MS); the backend
|
// clip is actually audible (the service calls beginMainSilence on the
|
||||||
// fade thread still ends in pause + desired-paused latch, and its
|
// opening session's first MEDIA_PLAYING). Firing it at acquire time —
|
||||||
// failure mode is silence, never double audio.
|
// 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
|
// 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
|
// 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 }) {
|
export function createProductionFocus({ readPlayer, readPlaybackGeneration, resumeMain }) {
|
||||||
const gateway = createRevisionGateway();
|
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({
|
const coordinator = createFocusCoordinator({
|
||||||
gateway,
|
gateway,
|
||||||
mainStatus: () => {
|
mainStatus: () => {
|
||||||
@@ -58,17 +70,14 @@ export function createProductionFocus({ readPlayer, readPlaybackGeneration, resu
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
requestSilence: async (generationAtSnapshot) => {
|
requestSilence: async (generationAtSnapshot) => {
|
||||||
// Open crossfade (user decision 2026-07-23): the main player
|
// DO NOT fade here. Arm the commitment and grant; the audible
|
||||||
// FADES to silence over the session crossfade instead of hard-
|
// main fade-out is deferred to beginMainSilence, called by the
|
||||||
// pausing, and this resolves as soon as the backend fade thread
|
// service when the first preview clip actually sounds, so the two
|
||||||
// is running — the first preview clip ramps in OVER the fading
|
// ramps overlap instead of the main fading into a gap on open.
|
||||||
// main track. This deliberately relaxes the Phase 0.4 rule
|
// The generation-bound ack is synthesised renderer-side (the
|
||||||
// ("acknowledged silence before any preview audio"): the
|
// backend fade call, when it eventually runs, carries no
|
||||||
// overlap is the feature. The pause + desired-paused latch land
|
// generation of its own — nativeGeneration stays null).
|
||||||
// at the fade's end inside the backend thread; its failure mode
|
mainSilenceArmed = true;
|
||||||
// is silence (volume left at 0), never double audio.
|
|
||||||
const p = readPlayer();
|
|
||||||
await api.playerFadePauseAcked(CROSSFADE_MS, p.muted ? 0 : (p.volume ?? 0.7));
|
|
||||||
const generationNow = readPlaybackGeneration();
|
const generationNow = readPlaybackGeneration();
|
||||||
return {
|
return {
|
||||||
// Applied only if no new main track started mid-negotiation;
|
// Applied only if no new main track started mid-negotiation;
|
||||||
@@ -82,10 +91,29 @@ export function createProductionFocus({ readPlayer, readPlaybackGeneration, resu
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
resumeMain: (snapshot, opts) => {
|
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
|
gateway.userPlay(opts); // focus-internal: bumps nothing
|
||||||
resumeMain(snapshot);
|
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 };
|
return { coordinator, gateway };
|
||||||
}
|
}
|
||||||
@@ -64,6 +64,10 @@ export function createPreviewService({
|
|||||||
let settleTimer = null;
|
let settleTimer = null;
|
||||||
let focusToken = null;
|
let focusToken = null;
|
||||||
let focusWaiting = false;
|
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.
|
// Burst detection for the adaptive settle window.
|
||||||
let lastNavAt = -Infinity;
|
let lastNavAt = -Infinity;
|
||||||
// One early auto-advance per generation, however many position events
|
// One early auto-advance per generation, however many position events
|
||||||
@@ -94,6 +98,14 @@ export function createPreviewService({
|
|||||||
if (generationMoved && state.phase === PHASE.RESOLVING_MEDIA) {
|
if (generationMoved && state.phase === PHASE.RESOLVING_MEDIA) {
|
||||||
scheduleSettle();
|
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();
|
maybeScheduleEarlyAdvance();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,6 +335,10 @@ export function createPreviewService({
|
|||||||
state = createInitialState(sessionId);
|
state = createInitialState(sessionId);
|
||||||
emit();
|
emit();
|
||||||
const granted = await ensureFocus();
|
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 });
|
dispatch({ type: "MANIFEST_READY", sessionId, manifest, startAtManifestIndex });
|
||||||
if (!granted && state.phase !== PHASE.EMPTY) {
|
if (!granted && state.phase !== PHASE.EMPTY) {
|
||||||
dispatch({ type: "SET_DESIRED_PLAYING", sessionId, value: false });
|
dispatch({ type: "SET_DESIRED_PLAYING", sessionId, value: false });
|
||||||
@@ -411,6 +427,10 @@ export function createPreviewService({
|
|||||||
scheduler.clear(settleTimer);
|
scheduler.clear(settleTimer);
|
||||||
settleTimer = null;
|
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 });
|
dispatch({ type: "CLOSE", sessionId: state.sessionId });
|
||||||
prefetched.clear();
|
prefetched.clear();
|
||||||
const fades = typeof transport.fadeOut === "function";
|
const fades = typeof transport.fadeOut === "function";
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ function makeTransport() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function makeFocus({ grant = true, pausedByPreview = true } = {}) {
|
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;
|
let live = null;
|
||||||
return {
|
return {
|
||||||
calls,
|
calls,
|
||||||
@@ -111,6 +111,9 @@ function makeFocus({ grant = true, pausedByPreview = true } = {}) {
|
|||||||
live = { id: `t${calls.acquire}`, pausedByPreview };
|
live = { id: `t${calls.acquire}`, pausedByPreview };
|
||||||
return { granted: true, token: live };
|
return { granted: true, token: live };
|
||||||
},
|
},
|
||||||
|
beginMainSilence() {
|
||||||
|
calls.beginMainSilence += 1;
|
||||||
|
},
|
||||||
release(token) {
|
release(token) {
|
||||||
calls.release += 1;
|
calls.release += 1;
|
||||||
const ok = token === live;
|
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"));
|
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 () => {
|
test("open with focus denied: session opens PARKED, wait state surfaced", async () => {
|
||||||
const { service, scheduler, transport, manifest } = makeService({ grant: false });
|
const { service, scheduler, transport, manifest } = makeService({ grant: false });
|
||||||
await service.open({ sessionId: SID, manifest });
|
await service.open({ sessionId: SID, manifest });
|
||||||
|
|||||||
Reference in new issue
Block a user