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
36 changes: 36 additions & 0 deletions demos/realtime_motion_graph_web/web/hooks/useStartSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,36 @@ async function restoreRefs(remote: RemoteBackend): Promise<void> {
);
}

/** Minimum loop-region length in seconds. Mirrors `MIN_BAND_SEC` in
* WaveformScrubBox — below this the band is meaningless and the editor
* never mirrors it to the worklet / server. */
const MIN_LOOP_BAND_SEC = 0.05;

/**
* Re-send the operator's active loop band to a freshly (re)connected
* backend. Like timbre / structure refs, the loop band is NOT part of
* SessionConfig, and the only other sender (WaveformScrubBox's sync
* effect) does not re-run on reconnect — the AudioPlayer persists and
* `loopBand` is unchanged. So without this the new server session never
* learns the band: it decodes against the full timeline while the
* worklet keeps looping locally, drifting into a stale loop at the seam.
* Mirrors restoreRefs: read the source-of-truth store and re-apply to
* the new remote. The activity gate matches WaveformScrubBox exactly so
* we only send a band the listener is actually hearing loop.
*/
export function restoreLoopBand(remote: RemoteBackend): void {
const perf = usePerformanceStore.getState();
const band = perf.loopBand;
const active =
band !== null &&
perf.bandLoopEnabled &&
band.end - band.start >= MIN_LOOP_BAND_SEC &&
band.start >= 0;
if (active) {
remote.sendLoopBand(band.start, band.end);
}
}

export function useStartSession() {
return useCallback(async () => {
const { setStatus, setSession, reset } = useSessionStore.getState();
Expand Down Expand Up @@ -620,6 +650,12 @@ export function useStartSession() {
// refs aren't carried in buildConfig, so without this a
// reconnect silently drops them.
await restoreRefs(remote);
// Same story for the active loop band: not in buildConfig, and
// WaveformScrubBox's sender effect doesn't re-run on reconnect
// (the AudioPlayer persists, loopBand unchanged). Without this
// the worklet keeps looping locally while the new server
// decodes the full timeline → drift / stale loop at the seam.
restoreLoopBand(remote);
},
{
onAttempt: ({ attempt, maxAttempts }) => {
Expand Down
55 changes: 55 additions & 0 deletions demos/realtime_motion_graph_web/web/tests/unit/wsReconnect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { WsReconnector, type ReconnectAttempt } from "@demon/client";
import type { RemoteBackend } from "@demon/client";
import { restoreLoopBand } from "@/hooks/useStartSession";
import { usePerformanceStore } from "@/store/usePerformanceStore";

describe("WsReconnector", () => {
beforeEach(() => {
Expand Down Expand Up @@ -123,3 +126,55 @@ describe("WsReconnector", () => {
r.cancel();
});
});

// Regression: the reconnect success path re-applies session state that
// isn't carried in SessionConfig (timbre/structure refs, and — the bug
// this guards — the active loop band). WaveformScrubBox's sender effect
// doesn't re-run on reconnect (the AudioPlayer persists, `loopBand`
// unchanged), so without `restoreLoopBand` the freshly reconnected
// backend never learns the band: the worklet keeps looping locally
// while the server decodes the full timeline → drift / stale loop.
describe("restoreLoopBand on reconnect", () => {
const pristine = usePerformanceStore.getState();
beforeEach(() => {
usePerformanceStore.setState(pristine, true);
});

function fakeRemote(): { remote: RemoteBackend; sendLoopBand: ReturnType<typeof vi.fn> } {
const sendLoopBand = vi.fn();
return { remote: { sendLoopBand } as unknown as RemoteBackend, sendLoopBand };
}

it("re-sends the active loop band with its exact region", () => {
usePerformanceStore.getState().setLoopBand({ start: 2.5, end: 6.0 });
usePerformanceStore.getState().setBandLoopEnabled(true);
const { remote, sendLoopBand } = fakeRemote();
restoreLoopBand(remote);
expect(sendLoopBand).toHaveBeenCalledTimes(1);
expect(sendLoopBand).toHaveBeenCalledWith(2.5, 6.0);
});

it("does not send anything when no band is set", () => {
// Pristine store: loopBand is null even though bandLoopEnabled defaults on.
expect(usePerformanceStore.getState().loopBand).toBeNull();
const { remote, sendLoopBand } = fakeRemote();
restoreLoopBand(remote);
expect(sendLoopBand).not.toHaveBeenCalled();
});

it("does not send a band that is armed-but-off (loop disabled)", () => {
usePerformanceStore.getState().setLoopBand({ start: 1.0, end: 4.0 });
usePerformanceStore.getState().setBandLoopEnabled(false);
const { remote, sendLoopBand } = fakeRemote();
restoreLoopBand(remote);
expect(sendLoopBand).not.toHaveBeenCalled();
});

it("ignores a degenerate sub-50ms region", () => {
usePerformanceStore.getState().setLoopBand({ start: 1.0, end: 1.02 });
usePerformanceStore.getState().setBandLoopEnabled(true);
const { remote, sendLoopBand } = fakeRemote();
restoreLoopBand(remote);
expect(sendLoopBand).not.toHaveBeenCalled();
});
});