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:
KeelrLeek committed 2026-07-23 16:54:09 -05:00
1 parent 691fdcdc9a
commit abc959ca2f
3 files changed
+88 -19

No files matched your search

+46 -18
View File
@@ -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 };
}
+20
View File
@@ -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";
+22 -1
View File
@@ -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 });