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

+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 });