← All docs
Ideas

Sequence engine — cue lists, layers, priority

> Track B. Read after README.md. Track B does NOT need to read the hardware backend docs — it only writes to universeBus.

A sequence (TagTable calls them "templates") is a saved cue list: time-stamped channel writes that play out over a fixed duration, optionally looping. Multiple sequences can be active simultaneously, each tracking its own playhead and fade state. The mixer (06-mixer.md) decides what actually goes to the hardware.

The sequence schema

Generalized from TagTable's RGB-only [r,g,b] cues to multi-channel writes against a fixture. Stored as JSON in the existing config (settings.dmx.sequences).

export interface Sequence {
  id: string;
  name: string;                // user-displayed
  color: string;               // pad swatch hex
  duration: number;            // seconds; loop wraps at this point
  loop: boolean;
  // Targeted fixture instance. The sequence references a fixture profile +
  // universe + start channel; cues then write per-role values, just like a
  // dmx-out node. Sequences and dmx-out nodes share the same value model.
  target: {
    universeId: string;
    fixtureProfileId: string;
    startChannel: number;
  };
  cues: SequenceCue[];
  // Mixer hints — see 06-mixer.md.
  slot?: string;               // exclusivity group; "" or undefined = no slot
  basePriority?: number;       // tiebreaker within a slot; lower numbers ride below higher
  mixMode?: "replace" | "max" | "add";
}

export interface SequenceCue {
t: number; // seconds from sequence start; cues sorted by t
values: {
r?: number; g?: number; b?: number; w?: number; a?: number; uv?: number;
dimmer?: number; strobe?: number; function?: number;
raw?: Record<number, number>;
};
fade?: number; // ms; 0 = snap; otherwise interpolate from prior cue
// Optional: emit a synthetic input event when this cue fires. Lets the user
// wire "the bass-drop cue" to fire OSC packets. Same shape as inputBus.
emit?: { signature: string; value?: number };
}

The layer engine

A "layer" is a running instance of a sequence. Multiple layers can exist for the same sequence id (TagTable disallows this; we allow it but mark it confusing in the UI).

export interface SequenceLayer {
  id: string;                            // uid; not the sequence id
  sequenceId: string;
  startTime: number;                     // performance.now() when started, or master-clock origin if shared
  nextCueIndex: number;
  priority: number;                      // mixer's priority — see 06-mixer.md
  // Per-role current values (after fade interpolation). Mixer reads these.
  currentValues: SequenceCue["values"];
  fadeStates: Map<string, FadeState>;    // per-role fade in flight
  finished: boolean;
  // For shared-clock mode: which master clock id this layer obeys.
  clockId: string | null;
}

export interface FadeState {
fromValue: number;
toValue: number;
startTs: number;
durationMs: number;
}

The master tick

One requestAnimationFrame loop in the renderer drives all layers. TagTable does this in src/renderer/app.js:lightingMasterTick (line 3704). We follow the shape exactly, but write per-role instead of per-RGB.

// src/features/dmx/sequenceEngine.ts
function masterTick(now: number): void {
  for (const layer of activeLayers) {
    const seq = sequences.get(layer.sequenceId);
    if (!seq) { finishedLayers.push(layer.id); continue; }
    advancePlayhead(layer, seq, now);
    interpolateFades(layer, now);
  }
  removeFinished();
  emitToMixer();   // see 06-mixer.md — composes layers into universe writes
  if (activeLayers.length > 0) raf(masterTick);
  else stopMasterTick();
}

advancePlayhead:

  • compute elapsed = (now - layer.startTime) / 1000.
  • if elapsed >= seq.duration: loop or mark finished.
  • while there's a next cue and elapsed >= cues[next].t: apply it. If the cue has a fade > 0, schedule a fade per role; else snap.

interpolateFades:
  • for each entry in layer.fadeStates, compute t = (now - fadeStart) / duration, lerp from..to, write into layer.currentValues. When t >= 1, drop the fade entry.

Keep both functions under 40 lines each. Long control flow is fine; nested helpers are not.

Sequence operations

src/features/dmx/sequenceOps.ts:

export function startSequence(seqId: string, opts?: { clockId?: string }): string;  // returns layer id
export function stopSequence(layerId: string): void;
export function stopBySequenceId(seqId: string): void;        // stops all layers of that seq
export function blackout(): void;                              // stops every layer
export function isLayerActive(seqId: string): boolean;
export function getTopLayerForSequence(seqId: string): SequenceLayer | null;

These are the calls the pad launcher (05-pad-launcher.md) makes.

Resuming below higher-priority

TagTable's behaviour: only the top-priority layer outputs; lower layers continue ticking but their writes are ignored. When the top layer stops, the next-highest takes over at its current playhead, not from the start. This gives the "the cue that was hidden is still there" feel that VJs want.

Our mixer (06-mixer.md) makes this explicit per slot. Layers in different slots can run concurrently with mix rules; layers in the same slot exclusively output the highest-priority one.

Implementation: currentValues is updated every tick for every layer. The mixer decides which values reach the universe bus. So a "hidden" layer's playhead and fade state stay live — it's only the write that's gated.

Persistence

%APPDATA%/XOSC/sequences.json   — array of Sequence objects

Loaded on boot, saved on every change (debounced through the existing useConfigSync flow — sequences live under settings.dmx.sequences).

Built-in presets: ship 8 starter sequences inspired by TagTable's generatePresetTemplates (config.js:437+) but generalized to any RGB fixture profile. See 05-pad-launcher.md for the seed list.

What you'll write

src/features/dmx/
├── sequenceEngine.ts       # masterTick + active-layer state
├── sequenceOps.ts          # start/stop/blackout API
├── sequenceFade.ts         # fade math, kept tiny
├── sequencePresets.ts      # 8 built-in sequences
└── sequenceTypes.ts        # Sequence, SequenceCue, SequenceLayer, FadeState

Don't do

  • Don't put the engine in the main process. TagTable runs it in the renderer with rAF and that's the right call — it lets the pad UI react instantly to playhead state without an IPC roundtrip on every frame. The mixer then bulks writes through universeBus.writeMany once per render frame (~16 ms).
  • Don't recompute fixture profile applies on every tick. Cache the role→channel mapping per sequence at start.
  • Don't try to support cue-relative timing ("cue 3 fires 200 ms after cue 2"). Cues are absolute against the sequence's startTime. If users want relative timing, they edit the cue list values manually. v0.7 polish.