Skip to content

fix(rtmg/web): reconnect keeps playing the stale audio-source track#274

Closed
qianghan wants to merge 1 commit into
daydreamlive:mainfrom
qianghan:fix/source-mode-reconnect-stale-playback
Closed

fix(rtmg/web): reconnect keeps playing the stale audio-source track#274
qianghan wants to merge 1 commit into
daydreamlive:mainfrom
qianghan:fix/source-mode-reconnect-stale-playback

Conversation

@qianghan

Copy link
Copy Markdown

Bug

When you are in audio source mode, disconnect, then quickly get to the sequencer and reconnect, it will continue playing the track that was loaded while the sequencer UI is showing and playing.

After an abnormal WS close, if the user switches to a different audio source during the auto-reconnect window, the recovered session keeps playing the previously-loaded track even though the UI now shows the new selection.

Root cause analysis

The reconnect path snapshots the fixture at session start and never reconciles it against the user's current selection on recovery:

  1. Snapshot at connect. useStartSession resolves the active fixture once and freezes it into sessionFixture (useStartSession.ts, resolveFixtureForConnect + the const sessionFixture snapshot). The reconnect factory (buildAndConnect) rebuilds every backoff attempt from that snapshot — by design, to avoid re-decoding multi-MB uploads on every retry. Its comment even asserts "the user can't have changed fixtures while a 'Reconnecting…' placard is up", which is exactly the wrong assumption here.

  2. Mid-outage track change is dropped. When the user picks a new track, usePerformanceStore.fixture updates and useFixtureSwap's subscription calls run(name). But run() early-returns when session.status !== "ready" (useFixtureSwap.ts): during "reconnecting" the swap is silently dropped and lastSwappedTo is left untouched. Nothing re-applies it once the socket recovers.

  3. Recovery rebinds the stale track. On success the reconnect factory connects with the snapshot config and calls player.swap(remote.initialBuffer) — i.e. the backend re-encodes and the player swaps to the old track's clean source. The UI (driven by usePerformanceStore.fixture) shows the new track; audio + server session are pinned to the stale one.

Symptom vs. cause: the symptom is stale playback after reconnect; the true cause is that the session's bound fixture (snapshot) and the UI's selected fixture can diverge during an outage, and the reconnect completion never reconciles them.

Note: stem source-mode (full/vocals/instruments) changes during an outage were already safe, because the reconnect's buildConfig re-reads resolveSourceMode() live — only the fixture name (and its decoded PCM) is snapshotted, which matches the report ("the track that was loaded").

Fix plan

  • Add boundFixture to useSessionStore — the single source of truth for the track the live session is actually bound to, set on initial connect and on every reconnect, cleared on reset().
  • In useFixtureSwap, reconcile on the "reconnecting""ready" edge: if the selected fixture diverged from boundFixture, re-run the swap exactly once (the existing, contract-respecting swap path).
  • Keep boundFixture in sync after a successful swap.
  • No regressions: the reconcile is gated to fire only on the reconnect→ready transition with a real divergence. Decision lives in the pure needsFixtureReconcile():
    • Normal reconnect (selection unchanged): selected === bound → no swap.
    • Fresh Play / sequencer-only (idle/connectingready): never the reconnecting→ready edge → no swap; useStartSession already binds the current selection.
    • Audio-source session that didn't change track: no divergence → no swap.
  • No wire-contract / knob registry changes, so no gen_wire_types.py regen needed (client-only state).

Code change summary

  • store/useSessionStore.ts: new boundFixture field + setBoundFixture, cleared on reset().
  • hooks/useStartSession.ts: record boundFixture on initial connect and (before status flips to ready) on reconnect.
  • hooks/useFixtureSwap.ts: export pure needsFixtureReconcile(); subscribe to the session store and reconcile on reconnect→ready; update boundFixture after a swap.
  • Tests: new tests/unit/fixtureReconnectReconcile.test.ts (decision matrix); tests/unit/sessionStore.test.ts asserts boundFixture clears on reset.

Verification

npm run typecheck → clean.

npm run build✓ Compiled successfully, Finished TypeScript, static pages generated.

npx vitest run tests/unit/fixtureReconnectReconcile.test.ts tests/unit/sessionStore.test.ts13 passed.

Fails-before evidence: with the source changes reverted but the new test present, all 5 needsFixtureReconcile cases fail (helper absent); they pass with the fix applied.

The only failing tests in the suite (sliceEpoch, float16, replay "refs cache") are a pre-existing Math.f16round is not a function environment gap in the local Node build — unrelated to this change and failing identically on the base commit.

Made with Cursor

…ing an outage doesn't keep playing stale

When the WS drops and the user switches to a different audio source before
the auto-reconnect completes, the recovered session kept playing the
previously-loaded track even though the UI showed the new selection.

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 while the perf store's fixture
shows the new pick.

Fix: track the live session's bound fixture in useSessionStore
(boundFixture), set on initial connect and reconnect. useFixtureSwap now
reconciles on the "reconnecting" -> "ready" edge: if the selected fixture
diverged from the bound one, it re-runs the swap exactly once. No effect
on a fresh Play or a clean reconnect where the selection never changed.

Adds needsFixtureReconcile() + a focused unit test pinning the decision
matrix, and asserts boundFixture clears on session reset.

Co-authored-by: Cursor <cursoragent@cursor.com>
@seanhanca

Copy link
Copy Markdown

Superseded by #278, re-opened from a branch on this repo (daydreamlive/DEMON) instead of the qianghan fork. Closing this fork-based PR in favor of #278.

@seanhanca seanhanca closed this Jun 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants