> 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).max—Math.max(...candidates). Best for additive RGB lighting where layered cues should brighten, not flicker.add—Math.min(255, sum(...candidates)). Same idea but cumulative.
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.mdcovers 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.