Internal · SpaceMusic Engineering

A/B Runtime Architecture for Scene Transitions

The technical companion to Scene Loading and Smooth Transitions — where the doubling lives in the C# class tree, what we'd build in what order, and the open questions we'll need to answer before shipping.

What this document is

The framing doc argued that scene transitions should be a continuous architectural property, not a hard cut. This is the implementation companion. It picks the specific seam in the C# code that we double, lays out a five-phase rollout, and is honest about what we still don't know.

It builds on two prior pieces of work. Plan 033 (file-management) moved every file path into the channel model, so a scene is now a clean serializable snapshot of ProjectModel — no separate "files" registry, no patch-side bookkeeping. The work proposed here goes one layer up: from how scene state is stored to how the engine transitions between two states.

Two facts about the existing architecture shape everything that follows. First, the runtime tree (the RuntimeModel class and its *RuntimeModel children) already exists as a separate top-level tree alongside ProjectModel. Live texture outputs are not part of the serializable scene — they're a sibling. Second, the channel hub is path-agnostic: it can host multiple roots without any infrastructure change. The codegen, however, currently emits a single ProjectModel class. That asymmetry is the constraint that decides which seam we double first.

Constraints we're honoring

Five pieces of the architecture we're not touching, by choice:

  1. Single process, single GPU. Both runtimes share the same Stride GraphicsDevice. No cross-process protocol, no shared GPU handles. Two-runtime work happens inside one process. Avoids the V1 flicker class entirely · matches plan 033's posture
  2. ProjectModel stays single and serializable. The operator continues to edit one scene at a time. Save/load remains a single JSON snapshot. No wrapper container, no SceneA/SceneB dual-edit surface in Phase 1. Smaller blast radius · defers the codegen rewrite
  3. Channel hub is the universal API. Every new blend primitive must work through IChannelHub / PublicChannelHelper, not bypass it. No second event bus, no parallel binding layer. Same contract for local, dual-screen, and remote clients
  4. Codegen pipeline owns generation. New types (RuntimeSnapshot, blend descriptors) come through SMCodeGen if they touch the parameter hierarchy. Hand-written types remain confined to SpaceMusicMeta (interfaces, helpers). Same model as plan 033 · same audit trail
  5. Plugin texture publication is unchanged. Plugin1DRuntimeModel.Plugin1DTexture and its siblings still publish a single UiTexture. The doubling happens at the parent — two RuntimeModel trees, each with its own *RuntimeModel children — not by mutating the per-plugin runtime class. Plugin authors don't see the change

The decision: double the runtime, single the model

The cleanest seam in the existing architecture is the line between the serializable ProjectModel and the live RuntimeModel. We double everything below the seam; everything above stays as it is.

Figure 1 · Where the seam lives — single model above, doubled runtime below

THE SEAM SNAPSHOT DRIVE x MODEL ProjectModel · single, serializable plugin selections · render mode · file paths · float values RUNTIME A RuntimeModel A · frozen copy of state at last load keeps rendering until x = 1 the outgoing scene RUNTIME B RuntimeModel B · live driven by current ProjectModel renders the incoming scene the incoming scene CROSSFADER Channel lerp + texture blend consumed at every texture site x ∈ [0,1] crossfade coefficient — blended outputs flow to consumers (3D render, audio mixer, previews) —

In practice the change looks like this. At load time, the current RuntimeModel is copied into RuntimeA — a frozen instance that keeps producing textures from the state it had at the moment of the copy. ProjectModel is then rewritten by LoadScene as usual, and RuntimeB rebinds to the new model. Both runtimes coexist; a crossfade coefficient x walks from 0 toward 1 over the transition duration. When it reaches 1, RuntimeA is torn down.

This buys us transition smoothing — the visible part of what the framing doc argued for. What it does not buy is operator pre-cueing: the DJ workflow where Scene B is edited while Scene A plays. Pre-cueing requires doubling the serializable model too, so the operator's edit surface has somewhere to point. The reason to defer that is concrete: doubling ProjectModel means rewriting the codegen to handle a wrapper container, touching every channel-path consumer in the patch and the Pro UI. Doubling only the runtime tree is contained — the runtime tree has a much smaller consumer surface, and most of its consumers are texture-side, which we have to touch anyway.

The seam, in other words, is the boundary we already inherit. We are not introducing a new abstraction; we are using one that's already there.

How the blend works, per parameter tier

The framing doc's three tiers — direct lerp, output blend, no interpolation — each land somewhere different in this architecture.

Tier 1 · Direct lerp

Every ParamFloat channel is fed by a small helper that reads the snapshot value, the current ProjectModel value, and the coefficient x, and writes lerp(snapshot, current, x) to the public channel. The helper runs per-frame, hooked into the same Update(hub) tick that DirectChannelProvider and LoadFileService already use. Both runtimes read the same blended channel — they don't see independent A and B float values. Floats don't actually double; they blend at the source.

The corresponding code surface is small: one new helper type (call it ChannelLerpDriver), one entry in the per-frame update list, and an enumeration of which channels are ParamFloat — which the existing codegen manifest can provide.

Tier 2 · Output blend

This is where most of the work is. Every *RuntimeModel publishes a UiTexturePlugin1DTexture, Plugin2DTexture, Plugin3DTexture, RenderModeTexture, PostFXTexture, LightTexture, StageTexture. With doubled runtimes, each of these exists in two places. The consumption side — the 3D render, the material inputs, the preview panes — needs a blend primitive: lerp(textureA, textureB, x) evaluated when the texture is sampled.

The unknown here is the consumption-site count. From the C# side we publish, but the actual texture sampling happens across SpaceMusic.vl and the Stride scene tree. Phase 3 begins with an enumeration of those sites — the audit is the work — and the implementation is a small SDSL shader (or a Fuse mixin) applied at each.

Tier 3 · Categorical, hidden inside Tier 2

The categorical parameters — render-mode enums, plugin selections, file paths — live in ProjectModel and configure RuntimeB. RuntimeA, frozen at the moment of the last load, was configured by the previous categorical values and keeps rendering with them. The Tier 2 texture blend at the consumption site is what makes the transition smooth: RuntimeA renders the old mode, RuntimeB renders the new mode, and the lerp at the texture stage hides the jump.

The implication is important: we don't need to interpolate enums or strings, ever. The categorical state is fixed inside each runtime; the only thing that blends is the rendered output. This is what makes the A/B model fundamentally different from per-parameter automation — categorical parameters can change instantly inside a runtime, because the audience never sees that runtime in isolation during a transition.

The phased rollout

Five phases, in dependency order. Phase 3 is the largest piece by a comfortable margin; everything else is bounded.

Figure 2 · Phased rollout — five phases, with Phase 3 carrying most of the implementation hours

PHASE 01 Snapshot mechanism freeze RuntimeA Copy leaf values from RuntimeModel into a frozen parallel instance. prerequisite PHASE 02 Crossfade coefficient x + duration Public channel for x and transition duration. Driven by a timer or hand. PHASE 03 Texture blend at consumers SDSL · per site Enumerate every plugin-texture site. Add lerp shader to each consumer. the bulk of work PHASE 04 Channel lerp (Tier 1) ParamFloat helper Per-frame helper: lerp(snap, cur, x) for every float channel listed. PHASE 05 Operator UI + scheduling trigger · Link Pro UI Transition button. Beat-aware scheduling via Ableton Link. FUTURE · DEFERRED Dual ProjectModel for operator pre-cueing — separate plan, triggered when the Pro UI needs to edit Scene B while Scene A plays.

Phase 1 — Snapshot mechanism. Introduce a RuntimeSnapshot type that copies the live RuntimeModel's leaf values into a frozen parallel instance. The frozen instance is fully attached to the channel hub — it just stops being driven by ProjectModel. This is the prerequisite for everything else; nothing in later phases makes sense without two runtimes coexisting.

Phase 2 — Crossfade coefficient. Add the public channels for x ∈ [0,1] and transition duration. Drive x from a timer initially; later it gets human and automation drivers. Small surface; lands in SpaceMusicMeta alongside LoadScene.

Phase 3 — Texture blend at consumers. The big one. An audit of every site that reads a UiTexture from a *RuntimeModel, plus a small SDSL helper (or Fuse mixin) that performs the per-pixel lerp. Each consumption site picks the helper instead of reading the texture directly. This phase is large not because any single change is hard, but because the consumption sites are scattered through the Stride render and the patch.

Phase 4 — Channel lerp helper. The Tier-1 driver. Walks the codegen manifest for ParamFloat channels, runs once per frame, writes the lerped value. Small file, well-scoped.

Phase 5 — Operator UI + scheduling. A Transition button in the Pro UI. Optional beat-quantized scheduling via Ableton Link (we already integrate Link for audio). Automation hooks so the headless ABB-style installation can drive transitions on a schedule. Builds on everything above.

Open questions

Six things we don't have answers to yet. None of them block starting Phase 1; all of them want answers before Phase 3 commits.

  1. How many texture-consumption sites exist? The blast radius of Phase 3 depends entirely on this count. A focused audit of SpaceMusic.vl and the Stride scene tree is the first task. if <10 sites: per-site primitive · if >30: centralize via a wrapping accessor
  2. What's the scheduling time-base? Wall-clock seconds, Ableton Link beats, or a hybrid (operator-set duration in beats with Link slaving)? Affects the coefficient driver's design.
  3. Per-runtime GPU cost at 4K. Two complete plugin chains rendering simultaneously may or may not fit our frame budget. Need a measurement on a representative scene before we commit to all-plugins-doubled. Possible fallback: render RuntimeA at half resolution during the transition.
  4. Snapshot semantics for stateful plugins. Some plugins have internal feedback or accumulators (trails, audio reverb tails, particle systems). Does the snapshot include their internal state, or only the channel-leaf values? The answer determines whether a snapshotted runtime keeps evolving or freezes in place.
  5. File-asset pre-loading. When the new scene references audio or 3D files the old one didn't, those files load during the transition. Today's load is synchronous; under crossfade pressure it may stall the transition. Mitigation: pre-load during scene-pick rather than scene-apply.
  6. Audio crossfade curve. Equal-power, linear, or operator-pickable? The math is small; the taste decision wants user input, probably via an early prototype.

Why this shape

"Architecture is the question of which boundaries already exist and how we use them."

The choice to double only the runtime layer — and to keep ProjectModel single — is a conscious 80/20 cut. We get the visible benefit (smooth scene transitions during performance and during scheduled installations) with a contained change. The harder change (dual ProjectModel + operator pre-cueing) is unblocked but not required for the first ship. When we want it, the path is clear: wrap the single ProjectModel in a container that holds two, expose an "active scene" pointer, route RuntimeA and RuntimeB from their respective sides. None of that is invalidated by anything we do now.

The seam itself is the line between serializable scene state and live computed outputs — a boundary that already exists in the codebase. We are not inventing a new abstraction; we are doubling one that's already there. That makes the change small in conceptual surface even though Phase 3 (texture blend at consumption sites) is sizable in implementation hours.

Plan 033 made the model layer clean. This work makes the runtime layer the same. The next step beyond — operator pre-cueing — gets to inherit both, and lands as an additive workflow change rather than an architectural rewrite. That sequencing is the win.

Settled

The runtime tree is already separate

The existing RuntimeModel sits alongside ProjectModel as a top-level sibling, not as a child. Doubling it doesn't require any model-layer refactor — the seam is structural, not introduced.

Next step

Phase 1 + Phase 2

Snapshot mechanism + crossfade coefficient. Small and contained. Lets us validate the two-runtime model is sound before committing to Phase 3's consumption-site audit.

Open

Texture-site audit

The first concrete artefact wanted: a list of every UiTexture consumption site in the render pipeline. Sizes Phase 3; informs whether we go per-site or centralize behind a wrapping accessor.