> 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 afade > 0, schedule a fade per role; else snap.
interpolateFades:- for each entry in
layer.fadeStates, computet = (now - fadeStart) / duration, lerpfrom..to, write intolayer.currentValues. Whent >= 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.writeManyonce 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.