diff --git a/demos/realtime_motion_graph_web/web/hooks/useStartSession.ts b/demos/realtime_motion_graph_web/web/hooks/useStartSession.ts index 58e3690d..002b8a7c 100644 --- a/demos/realtime_motion_graph_web/web/hooks/useStartSession.ts +++ b/demos/realtime_motion_graph_web/web/hooks/useStartSession.ts @@ -430,6 +430,36 @@ async function restoreRefs(remote: RemoteBackend): Promise { ); } +/** 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(); @@ -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 }) => { diff --git a/demos/realtime_motion_graph_web/web/tests/unit/wsReconnect.test.ts b/demos/realtime_motion_graph_web/web/tests/unit/wsReconnect.test.ts index 796124d1..1c7d7d8d 100644 --- a/demos/realtime_motion_graph_web/web/tests/unit/wsReconnect.test.ts +++ b/demos/realtime_motion_graph_web/web/tests/unit/wsReconnect.test.ts @@ -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(() => { @@ -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 } { + 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(); + }); +});