diff --git a/demos/realtime_motion_graph_web/web/hooks/useFixtureSwap.ts b/demos/realtime_motion_graph_web/web/hooks/useFixtureSwap.ts index d19298a6..c7588243 100644 --- a/demos/realtime_motion_graph_web/web/hooks/useFixtureSwap.ts +++ b/demos/realtime_motion_graph_web/web/hooks/useFixtureSwap.ts @@ -13,7 +13,7 @@ import { } from "@/lib/config"; import { useCustomTracksStore } from "@/store/useCustomTracksStore"; import { usePerformanceStore } from "@/store/usePerformanceStore"; -import { useSessionStore } from "@/store/useSessionStore"; +import { useSessionStore, type SessionStatus } from "@/store/useSessionStore"; import { isTimeSignature } from "@/types/engine"; // In-place fixture swap. Mirrors swapToFixture() in DEMON's app.js: when the @@ -26,6 +26,36 @@ import { isTimeSignature } from "@/types/engine"; // state where it can't accept a new source). The full restart is delegated // back to useStartSession via the same fixture name. +/** + * Decide whether a recovered session must swap to the user's current + * track selection. + * + * The reconnect path (useStartSession) rebinds the fixture snapshotted + * at session start. If the user switched tracks while the socket was + * down, the perf store's `fixture` (what the UI shows) diverges from the + * session store's `boundFixture` (what the recovered backend + player + * actually play). Mid-outage the perf-store swap subscription fires but + * `run()` bails on `status !== "ready"`, so the change is dropped and + * never re-applied — the recovered session keeps playing the stale + * track. This guard re-applies it exactly once, the instant the + * reconnect completes (status "reconnecting" → "ready"). + * + * Returns false for every other transition so it never double-swaps a + * fresh Play (idle/connecting → ready) or a normal reconnect where the + * selection never changed. + */ +export function needsFixtureReconcile(args: { + prevStatus: SessionStatus; + status: SessionStatus; + selectedFixture: string; + boundFixture: string | null; +}): boolean { + const { prevStatus, status, selectedFixture, boundFixture } = args; + if (prevStatus !== "reconnecting" || status !== "ready") return false; + if (!selectedFixture) return false; + return selectedFixture !== boundFixture; +} + export function useFixtureSwap() { // Skip the very first fixture write (which fires when the catalog populates // and writes the default name into the store). @@ -241,6 +271,11 @@ export function useFixtureSwap() { return; } lastSwappedTo.current = name; + // The live session is now bound to this track server-side. Keep + // the session store in sync so the reconnect reconcile compares + // against the truth (and a later reconnect doesn't re-swap a track + // that's already loaded). + useSessionStore.getState().setBoundFixture(name); // A source-mode hotswap (force) is the same song with a different // stem feeding inference — skip the new-track gate entirely so the // performer's denoise / remix-started state is left untouched. @@ -305,6 +340,30 @@ export function useFixtureSwap() { void run(fixture, true); }); + // Reconnect reconcile: when a recovered session reaches "ready", the + // backend + player are bound to the fixture snapshotted at session + // start. If the user switched tracks while the socket was down, that + // mid-outage change was dropped by run()'s `status !== "ready"` + // bail. Re-apply it here exactly once on the reconnecting → ready + // edge so the recovered session swaps to the live selection instead + // of playing the stale track. `force` re-runs the swap even when the + // name matches lastSwappedTo (which still points at the stale bound + // fixture until the swap below updates it). + const unsubReconnect = useSessionStore.subscribe((s, prev) => { + const selectedFixture = usePerformanceStore.getState().fixture; + if ( + !needsFixtureReconcile({ + prevStatus: prev.status, + status: s.status, + selectedFixture, + boundFixture: s.boundFixture, + }) + ) { + return; + } + void run(selectedFixture, true); + }); + // Seed lastSwappedTo with the current fixture so the initial population // (catalog → default fixture write) doesn't trigger a no-op swap, and // seed lastSwappedMode so the first stem_assets echo is recognised. @@ -318,6 +377,7 @@ export function useFixtureSwap() { cancelled = true; unsub(); unsubSource(); + unsubReconnect(); }; }, []); } diff --git a/demos/realtime_motion_graph_web/web/hooks/useStartSession.ts b/demos/realtime_motion_graph_web/web/hooks/useStartSession.ts index 5cc79e99..e32a6825 100644 --- a/demos/realtime_motion_graph_web/web/hooks/useStartSession.ts +++ b/demos/realtime_motion_graph_web/web/hooks/useStartSession.ts @@ -618,6 +618,16 @@ export function useStartSession() { // tighter than what was previously enabled). applyLoraCapWithServerSync(resolveLoraCapForSource(remote.duration)); useSessionStore.getState().setSession(remote, player); + // Record what this recovered session is actually bound to. The + // reconnect rebinds the fixture snapshotted at session start, + // which can lag the user's current selection if they switched + // tracks while the socket was down. Set this BEFORE onSuccess + // flips status to "ready" so useFixtureSwap's reconnect + // reconcile observes the (possibly stale) bound fixture and + // heals the divergence by swapping to the live selection. + useSessionStore + .getState() + .setBoundFixture(sessionFixture.fixtureName); // Rebuild the network-quality monitor against the new // remote — the old one was bound to the dropped backend's // `slice` events and is dead now. @@ -753,6 +763,10 @@ export function useStartSession() { perfState.setRemixStarted(false); setSession(remote, player); + // The live session is now bound to this fixture. Mirrored into the + // session store so the reconnect path can tell whether the user + // switched tracks during an outage (see useFixtureSwap). + useSessionStore.getState().setBoundFixture(resolved.fixtureName); setStatus("ready", "Playing"); // Start the network-quality monitor now that the WS is "ready". diff --git a/demos/realtime_motion_graph_web/web/store/useSessionStore.ts b/demos/realtime_motion_graph_web/web/store/useSessionStore.ts index 3567def3..a88e4bf2 100644 --- a/demos/realtime_motion_graph_web/web/store/useSessionStore.ts +++ b/demos/realtime_motion_graph_web/web/store/useSessionStore.ts @@ -37,6 +37,15 @@ interface SessionState { /** Server-issued WS URL (from /api/queue/join). Null when no queue is in * use — useStartSession falls back to defaultWsUrl(). */ wsUrl: string | null; + /** Fixture name the LIVE session is actually bound to server-side. + * Distinct from usePerformanceStore.fixture (what the UI shows + * selected): the two diverge when the user picks a different track + * while the socket is down. The reconnect path rebinds to the + * fixture snapshotted at session start, so without reconciling + * against the current selection the recovered session keeps playing + * the stale track. useFixtureSwap reads this to detect and heal that + * divergence on reconnect. Null until the first session binds. */ + boundFixture: string | null; /** Active checkpoint's model-scale label ("2B" | "5B" | null). Set * from the WS ready message and from /api/loras. Null when unknown. * The LoRA library uses this to hide LoRAs whose trained @@ -78,6 +87,7 @@ interface SessionState { setMonitor: (monitor: NetworkMonitor | null) => void; setReconnector: (reconnector: WsReconnector | null) => void; setWsUrl: (wsUrl: string | null) => void; + setBoundFixture: (fixture: string | null) => void; setCheckpointScale: (scale: string | null) => void; setPipelineDepth: (depth: number | null) => void; setMaxPipelineDepth: (max: number | null) => void; @@ -99,6 +109,7 @@ export const useSessionStore = create((set, get) => ({ monitor: null, reconnector: null, wsUrl: null, + boundFixture: null, checkpointScale: null, pipelineDepth: null, maxPipelineDepth: null, @@ -115,6 +126,7 @@ export const useSessionStore = create((set, get) => ({ setMonitor: (monitor) => set({ monitor }), setReconnector: (reconnector) => set({ reconnector }), setWsUrl: (wsUrl) => set({ wsUrl }), + setBoundFixture: (fixture) => set({ boundFixture: fixture }), setCheckpointScale: (scale) => set({ checkpointScale: scale }), setPipelineDepth: (depth) => set({ pipelineDepth: depth }), setMaxPipelineDepth: (max) => set({ maxPipelineDepth: max }), @@ -139,6 +151,8 @@ export const useSessionStore = create((set, get) => ({ player: null, monitor: null, reconnector: null, + // Per-session truth: the next session rebinds its own fixture. + boundFixture: null, pipelineDepth: null, maxPipelineDepth: null, // Capability mask is per-session truth (the next session may pick diff --git a/demos/realtime_motion_graph_web/web/tests/unit/fixtureReconnectReconcile.test.ts b/demos/realtime_motion_graph_web/web/tests/unit/fixtureReconnectReconcile.test.ts new file mode 100644 index 00000000..07630450 --- /dev/null +++ b/demos/realtime_motion_graph_web/web/tests/unit/fixtureReconnectReconcile.test.ts @@ -0,0 +1,116 @@ +// Regression guard for: "Audio source mode reconnect keeps playing the +// stale track." +// +// Repro from the tracker: in audio-source mode with a track loaded, the +// socket drops; the user quickly switches to a different source while the +// "Reconnecting…" placard is up; when the session recovers it keeps +// playing the PREVIOUS track even though the UI now shows the new one. +// +// Root cause: the reconnect path (useStartSession) rebinds the fixture +// snapshotted at session start. The mid-outage track change is dropped +// because useFixtureSwap.run() bails while `status !== "ready"`, and +// nothing re-applies it once the session recovers. So the recovered +// backend + AudioPlayer stay bound to the stale track (session store's +// `boundFixture`) while the perf store's `fixture` shows the new pick. +// +// The fix adds a reconnect reconcile: on the "reconnecting" → "ready" +// edge, if the live `boundFixture` diverges from the selected `fixture`, +// re-run the swap exactly once. `needsFixtureReconcile` is that decision; +// these tests pin its matrix so the heal fires for the bug scenario and +// for nothing else (no double-swap on fresh Play or a clean reconnect). + +import { describe, expect, it } from "vitest"; + +import { needsFixtureReconcile } from "@/hooks/useFixtureSwap"; +import type { SessionStatus } from "@/store/useSessionStore"; + +const A = "inside_confusion_loop_60s_gsm.wav"; // track loaded at start +const B = "low_fi_Gm_loop_60s_gnm.wav"; // track picked during the outage + +describe("needsFixtureReconcile", () => { + it("heals the reported bug: track switched during the outage", () => { + // Recovered session is bound to A (the snapshot); the UI selection is + // B (chosen while the socket was down). The reconnect completed + // (reconnecting → ready) → reconcile must fire. + expect( + needsFixtureReconcile({ + prevStatus: "reconnecting", + status: "ready", + selectedFixture: B, + boundFixture: A, + }), + ).toBe(true); + }); + + it("does NOT fire on a clean reconnect (selection unchanged)", () => { + // The common case: the socket blipped, the user touched nothing. The + // bound track still matches the selection — no redundant swap. + expect( + needsFixtureReconcile({ + prevStatus: "reconnecting", + status: "ready", + selectedFixture: A, + boundFixture: A, + }), + ).toBe(false); + }); + + it("does NOT fire on a fresh Play (idle/connecting → ready)", () => { + // useStartSession already resolves the CURRENT selection and records + // it as boundFixture, so a fresh start never needs a reconcile — even + // if boundFixture hasn't been written yet at the instant of the edge. + for (const prevStatus of [ + "idle", + "loading-fixture", + "connecting", + ] as const satisfies readonly SessionStatus[]) { + expect( + needsFixtureReconcile({ + prevStatus, + status: "ready", + selectedFixture: B, + boundFixture: null, + }), + ).toBe(false); + expect( + needsFixtureReconcile({ + prevStatus, + status: "ready", + selectedFixture: B, + boundFixture: A, + }), + ).toBe(false); + } + }); + + it("ignores non-ready destinations of the reconnect edge", () => { + // Entering reconnect, giving up, and re-render churn must not swap. + for (const status of [ + "reconnecting", + "error", + "closed", + "idle", + ] as const satisfies readonly SessionStatus[]) { + expect( + needsFixtureReconcile({ + prevStatus: "reconnecting", + status, + selectedFixture: B, + boundFixture: A, + }), + ).toBe(false); + } + }); + + it("never fires with an empty selection", () => { + // No track selected yet → nothing to reconcile to. + expect( + needsFixtureReconcile({ + prevStatus: "reconnecting", + status: "ready", + selectedFixture: "", + boundFixture: A, + }), + ).toBe(false); + }); +}); diff --git a/demos/realtime_motion_graph_web/web/tests/unit/sessionStore.test.ts b/demos/realtime_motion_graph_web/web/tests/unit/sessionStore.test.ts index 3e20ab7b..ee066278 100644 --- a/demos/realtime_motion_graph_web/web/tests/unit/sessionStore.test.ts +++ b/demos/realtime_motion_graph_web/web/tests/unit/sessionStore.test.ts @@ -51,6 +51,7 @@ describe("useSessionStore", () => { const s = useSessionStore.getState(); s.setMonitor({ stop } as never); s.setReconnector({ cancel } as never); + s.setBoundFixture("track.wav"); s.setStatus("ready", "Playing"); s.reset(); expect(stop).toHaveBeenCalledTimes(1); @@ -61,6 +62,7 @@ describe("useSessionStore", () => { expect(after.reconnector).toBeNull(); expect(after.remote).toBeNull(); expect(after.player).toBeNull(); + expect(after.boundFixture).toBeNull(); expect(after.pipelineDepth).toBeNull(); expect(after.maxPipelineDepth).toBeNull(); expect(after.lastWsTrace).toBeNull();