Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 61 additions & 1 deletion demos/realtime_motion_graph_web/web/hooks/useFixtureSwap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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).
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -318,6 +377,7 @@ export function useFixtureSwap() {
cancelled = true;
unsub();
unsubSource();
unsubReconnect();
};
}, []);
}
14 changes: 14 additions & 0 deletions demos/realtime_motion_graph_web/web/hooks/useStartSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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".
Expand Down
14 changes: 14 additions & 0 deletions demos/realtime_motion_graph_web/web/store/useSessionStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand All @@ -99,6 +109,7 @@ export const useSessionStore = create<SessionState>((set, get) => ({
monitor: null,
reconnector: null,
wsUrl: null,
boundFixture: null,
checkpointScale: null,
pipelineDepth: null,
maxPipelineDepth: null,
Expand All @@ -115,6 +126,7 @@ export const useSessionStore = create<SessionState>((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 }),
Expand All @@ -139,6 +151,8 @@ export const useSessionStore = create<SessionState>((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
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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();
Expand Down