diff --git a/src/lib/preview/focusBridge.js b/src/lib/preview/focusBridge.js index 447f66e..38cb782 100644 --- a/src/lib/preview/focusBridge.js +++ b/src/lib/preview/focusBridge.js @@ -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 }; } diff --git a/src/lib/preview/service.js b/src/lib/preview/service.js index 581a73e..cfaf31c 100644 --- a/src/lib/preview/service.js +++ b/src/lib/preview/service.js @@ -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"; diff --git a/tests/preview/service.test.mjs b/tests/preview/service.test.mjs index 8c583be..71bf04f 100644 --- a/tests/preview/service.test.mjs +++ b/tests/preview/service.test.mjs @@ -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 });