> Track A. Read README.md first for context.
TagTable's src/main/dmx.js ships five working transmitters. Port each to XOSC's main process behind a single interface. No design rework on the protocols — they're battle-tested. We are translating, not inventing.
The interface
Single file: electron/dmx/transmitter.ts. Each backend implements:
export interface Transmitter {
readonly id: string; // user-facing key, "art-net-1", "serial-com3", etc.
readonly kind: TransmitterKind;
readonly universeId: string; // which universe this transmitter sends
open(): Promise<void>; // throws on failure
setUniverse(channels: Buffer): void; // 512 bytes; backend buffers internally
flush(): boolean; // emits one frame; returns false if dropped
status(): string; // human-readable for diagnostics row
close(): void;
}
export type TransmitterKind =
| "art-net" | "sacn" | "open-dmx" | "enttec-pro" | "slow-break";
The five backends — one file each
Keep these small. Each is ~80–150 lines.
electron/dmx/transmitters/artNet.ts
UDP unicast on port 6454. Sequence number byte rolls 1..255. Header Art-Net\0 + opcode 0x5000 + version 14.
- Tick: 23 ms (~43 Hz).
- Packet: 18 B header + 512 B data.
- Buffer the universe via
setUniverse(channels);flush()builds + sends one packet.
tagtable/src/main/dmx.js:97-167.
electron/dmx/transmitters/sacn.ts
E1.31 over UDP multicast at 239.255.<universe-high>.<universe-low>:5568. Full ACN packet (root + framing + DMP layers). CID generated once per process.
- Tick: 23 ms.
- Packet: 638 B (16 B + 110 B + 11 B + 513 B).
- Source name:
"XOSC"(TagTable uses"TagTable DMX").
tagtable/src/main/dmx.js:170-287.
electron/dmx/transmitters/openDmx.ts
FTDI USB serial chips that support the BREAK line via port.set({ brk: true }). 250 kbaud, 8N2.
- Tick: 33 ms (~30 Hz). The 513-byte frame at 250 kbaud takes ~23 ms wire time; break + drain adds the rest.
- Frame: 1 B start code (0x00) + 512 B data.
- Sequence: brk=true → sleep 1 ms → brk=false → write(513) → drain.
tagtable/src/main/dmx.js:361-386.
electron/dmx/transmitters/enttecPro.ts
ENTTEC USB DMX Pro framed protocol. 57600 baud, 8N1.
- Tick: 100 ms (~10 Hz). The framed message at 57600 baud is the bottleneck.
- Frame:
[0x7E][0x06][lenLSB][lenMSB][0x00 + 512 B][0xE7].
tagtable/src/main/dmx.js:388-408.
electron/dmx/transmitters/slowBreak.ts
CH340 / PL2303 adapters that don't expose a real BREAK line. Trick: switch baud to 90 kbaud, write 0x00 (10-bit frame stays low for ~111 µs > the 88 µs DMX BREAK requirement), then switch back to 250 kbaud and write the data frame.
- Tick: 50 ms (~20 Hz). Baud switching adds overhead.
tagtable/src/main/dmx.js:411-437.
The DMX engine
electron/dmx/engine.ts owns:
Map<universeId, Buffer>— one 512-byte buffer per universe.Map<transmitterId, Transmitter>— one transmitter per (kind, port, universe).- One
setIntervalper transmitter at its native tick rate. The shared 44 Hz loop TagTable uses doesn't fit when transmitters run at different rates; each transmitter pulls its own. - A pull from
universeBus.snapshot(universeId)on every tick.
// pseudo
function startTransmitter(t: Transmitter) {
const intervalMs = TICK_INTERVALS[t.kind];
const id = setInterval(() => {
if (!t.connected) return;
t.setUniverse(universeBus.snapshot(t.universeId));
const ok = t.flush();
diag.bump(t.id, ok ? "sent" : "dropped");
}, intervalMs);
intervals.set(t.id, id);
}
TICK_INTERVALS table from TagTable (dmx.js:459-465):
const TICK_INTERVALS: Record<TransmitterKind, number> = {
"art-net": 23,
"sacn": 23,
"open-dmx": 33,
"enttec-pro": 100,
"slow-break": 50,
};
Configuration shape
Each transmitter is configured by a small object that lives in the store under settings.dmx.transmitters:
interface TransmitterConfig {
id: string; // user-named, "stage-left-bar"
kind: TransmitterKind;
universeId: string; // logical universe id; many transmitters can share
// serial
serialPath?: string; // "COM3" / "/dev/ttyUSB0"
// network
ip?: string; // unicast for art-net; multicast auto-derived for sacn unless overridden
port?: number; // defaults: 6454 for art-net, 5568 for sacn
artnetUniverse?: number; // 0..32767 (Art-Net's universe number, distinct from our universeId)
sacnUniverse?: number; // 1..63999
// common
enabled: boolean;
tickIntervalOverride?: number; // 0 / undefined = auto from TICK_INTERVALS
}
Diagnostics
engine.ts keeps a per-transmitter counter set: framesSent, framesDropped, errors, lastErrorMsg, lastFrameTime, startTime. Computed fps, dropRate, health (good / warning / critical / no_output). Exposed via dmx:status IPC; renderer polls at 1 Hz for the diagnostics row.
Health rules (from TagTable dmx.js:704-718):
dropRate > 30%orfps < 5→ critical.dropRate > 10%orfps < 15→ warning.- No output yet →
no_output. - Else → good.
IPC surface
dmx:list-serial-ports → string[]
dmx:add-transmitter { cfg } → { ok, error? }
dmx:remove-transmitter{ id } → { ok }
dmx:update-transmitter{ id, cfg } → { ok }
dmx:status → { transmitters: TransmitterDiag[], universes: Array<{ id, channelMonitor: number[16] }> }
dmx:test-pattern { universeId } → starts the R/G/B/W cycle on universe channel 1-3 (best-effort)
dmx:test-pattern-stop → stops it
dmx:write-channel { universeId, channel, value } // bypass for test/debug
dmx:status polled by the diagnostics row. A push channel dmx:event exists for one-shot debug entries (open/close/error) so the renderer's debug log shows them without polling.
What you'll write
electron/dmx/
├── engine.ts # DmxEngine class, owns universes + transmitters + diag
├── transmitter.ts # interface + TICK_INTERVALS
├── transmitters/
│ ├── artNet.ts # ~120 lines
│ ├── sacn.ts # ~150 lines
│ ├── openDmx.ts # ~100 lines
│ ├── enttecPro.ts # ~80 lines
│ └── slowBreak.ts # ~110 lines
├── universeBus.ts # the shared bus the contract spec references
└── diag.ts # plain counter set per transmitter
electron/main.ts registers registerDmxHandlers(ipc, log, getWin) and disposes via disposeDmx(). Same pattern as peer-discovery.ts and osc-listener.ts.
Don't do
- Don't write the mixer. That's Track B. Until B lands,
universeBus.writeis last-write-wins; that is fine for graph-only DMX. - Don't write fixture profiles inline in transmitters. That's
02-fixtures.md. - Don't change
serialport's top-level singleton — share with the existingelectron/serial-bridge.tsonly if a port truly clashes (DMX adapters live on different COM ports than Arduino-style serial, in practice). - Don't import the renderer-side store from main. Configuration travels via IPC
config:saveonly.