← All docs
Ideas

Mixer — composing layers and graph writes into a universe frame

> Track B owns the mixer. Track A ships a placeholder (last-write-wins) so graph-only DMX is functional from day 1; Track B replaces it.

The mixer is the function that turns N concurrent writers into one 512-byte universe frame. Without it, two sequences writing to the same channel on the same tick would race. With it, they compose by rule.

The three writer kinds

type WriteSource =
  | { kind: "graph"; nodeId: string }            // dmx-out node fires; one shot per fire
  | { kind: "pad"; layerId: string;               // sequence layer; ticks every rAF
       slot?: string; priority: number;
       mixMode: "replace" | "max" | "add"; }
  | { kind: "art-net-in"; nodeId: string };      // art-net listener echo, rare

Each call to universeBus.write / writeMany carries one of these as source. The mixer reads the source kind and applies the right rule.

Composition rules

Per universe, the mixer keeps an intent map per channel:

// channel index 0..511 → list of staged writes that have NOT been mixed yet
type ChannelIntents = Map<number, StagedWrite[]>;
interface StagedWrite { value: number; source: WriteSource; ts: number; }

On every tick the mixer composes:

1. Graph writes are one-shot. They stage with ts = now. After composition, they expire (cleared on the next tick). A graph write that hasn't been re-issued has no effect on the next frame — it does not persist.
- Exception: triggerMode: "hold" graph writes re-fire on every press AND release; the engine doesn't need a hold; the node fires what it needs.

2. Pad-layer writes are continuous. The sequence engine writes the layer's currentValues to universeBus once per rAF tick (~60 Hz). Each layer keeps a slot, priority, and mixMode.

3. Art-Net inbound writes are one-shot per inbound frame, treated like graph writes.

Per-slot resolution

Within a slot, only the highest-priority layer outputs. Lower-priority layers in the same slot are silenced (their currentValues keep ticking, the mixer just ignores them).

Across slots

Layers from different slots compose by mix mode:

  • replace — the latest write wins (last-write-wins, but per-frame deterministic by priority).
  • maxMath.max(...candidates). Best for additive RGB lighting where layered cues should brighten, not flicker.
  • addMath.min(255, sum(...candidates)). Same idea but cumulative.
Default mixMode: "max" for sequences. replace for graph writes.

Graph + pad together

A graph dmx-out write to a channel that a pad layer also writes to: graph writes win because they're "user just pressed something." Implementation: graph writes apply after pad-layer composition.

final[channel] = graph_write_for_channel ?? compose_pad_writes(channel) ?? art_net_write_for_channel ?? 0

Empty channels stay 0 (the universe buffer is zeroed at the start of each compose pass — this matches TagTable's dmx.js:_tick line 638).

The compose function

Live in src/features/dmx/mixer.ts:

// Keep this function under 60 lines. It runs once per rAF tick.
export function composeFrame(universeId: string): Uint8Array;

Frame composition happens in the renderer. The renderer pulls active pad layers, composes them with any pending graph writes, and pushes the resulting 512-byte buffer to the main process via dmx:set-frame. Main writes that into the universe buffer; transmitters pick it up on their own tick.

This avoids per-frame IPC for individual channel writes and matches TagTable's "single frame buffer per universe" model.

Frame push protocol

dmx:set-frame { universeId, frame: Uint8Array }   → ack

The renderer rate-limits this to ~60 Hz max (rAF natural rate). Universes with no active writers skip the push (the universe buffer holds whatever was last set; if you stop all pads and have no graph writes, the universe stays at the last-known state until you blackout or fire a new write — that matches every other DMX engine's behaviour).

Blackout

blackout() does two things:

  • stops every active pad layer (04-sequence-engine.md covers this).
  • pushes an all-zero frame for every universe that has any transmitter active.

The all-zero frame stays valid until any new write touches the buffer. There's no "blackout latch" — the moment a new write happens the lights come back. This matches the user's expectation from TagTable.

Diagnostic surface

Track A's dmx:status already exposes per-universe channel monitor (first 16 channels). Track B doesn't add new IPC; it just uses the existing surface to show what the mixer composed.

A renderer-side mixerInspector.ts surfaces (for the diagnostics panel):

  • active layer count per slot
  • which layer is currently the "live" one per slot
  • count of staged graph writes in the last second

These are local state, no IPC.

What you'll write

src/features/dmx/
├── mixer.ts                  # composeFrame, rule application
├── mixerInspector.ts         # debug-panel-friendly snapshot
└── (extends universeBus.ts to call composeFrame at the right cadence)

Don't do

  • Don't try to be smart about LTP/HTP per-channel like a full lighting console. Two mix modes (max, add) plus slot exclusivity covers ~95% of use cases at this price point.
  • Don't compose in main. Renderer composes; main just receives the framed buffer.
  • Don't try to interpolate between frames at the mixer level — that's the per-layer fade engine's job in 04-sequence-engine.md.