> Track A. Read after 01-hardware-backends.md and 02-fixtures.md.
XOSC's router (src/features/mapping/useMappingRouter.ts) is already the right shape — multi-hop dispatch, per-edge throttle, transforms, depth cap. Adding DMX is one new sink (dmx-out) and one new source (dmx-in for Art-Net inbound). No router rewrite.
dmx-out (sink)
export interface DmxOutSpec {
universeId: string;
fixtureProfileId: string; // refers into FixtureProfile library
startChannel: number; // 1..512
// The role this node's continuous-input value overrides. For an RGB fixture
// wired to a slider, "dimmer" is the natural pick. For raw single-channel
// control, "raw:<offset>" picks a specific channel.
continuousRole: "dimmer" | "rgb-r" | "rgb-g" | "rgb-b" | "white" | "amber" |
"strobe" | "function" | { kind: "raw"; offset: number };
// Static values for every other role. Discrete inputs fire a snapshot of these.
staticValues: {
r?: number; g?: number; b?: number; w?: number; a?: number; uv?: number;
dimmer?: number; strobe?: number; function?: number;
raw?: Record<number, number>; // offset → 0..255
};
// Same triggerMode shape every other XOSC sink uses.
triggerMode: "pulse" | "toggle" | "hold";
// For toggle/hold: what to write on "off". Defaults to all-zeros.
offValues?: DmxOutSpec["staticValues"];
}
export interface DmxOutNodeData {
id: string;
kind: "dmx-out";
label: string;
color: string;
spec: DmxOutSpec;
position: { x: number; y: number };
}
Firing
In useMappingRouter.ts, fireSink gets a new branch:
if (sink.kind === "dmx-out") {
return await fireDmxOutput(sink.id, sink.spec, released, value, throttleMs);
}
fireDmxOutput resolves the fixture profile + applies values, then ships the write list to the main process via a single IPC call:
window.xosc.dmx.write({
universeId, writes: [[ch, val], ...], source: { kind: "graph", nodeId },
});
Discrete pulse fires the static snapshot. Toggle alternates between staticValues and offValues. Continuous inputs override one role's value with the normalized 0..1 (scaled to 0..255 for byte channels, 0..1.0 for fractional roles).
Inspector
src/features/inspector/DmxOutInspector.tsx. Sections:
1. Identity. Label + color (existing pattern).
2. Universe. Select of settings.dmx.transmitters[].universeId distinct values, plus a "+ Add transmitter" link to the settings panel.
3. Fixture. Select over allProfiles(userProfiles). Defaults to rgb.
4. Start channel. TextInput 1..512.
5. Static values. Generated from the fixture's channel roles. RGB fixtures get a <ColorPicker> (or the existing one); dimmer/strobe get <Slider>s. Roles the user marked as continuousRole are visibly disabled with a label "driven by source."
6. Continuous role. Select over the fixture's roles + a raw[N] option per channel.
7. Trigger mode. Reuse the OutputInspector pattern.
8. Test. Send-test snapshot button.
Use the existing Select, TextInput, Slider, Toggle, Button, IconButton from src/components/ui/. Theme variables only — no hardcoded colors.
Node body
src/features/mapping/DmxOutNode.tsx. Show:
- Universe + start channel pill (
U1 ch17). - Fixture name pill (
RGB Par · 3ch). - Live tiny color swatch reflecting the most recent write (subscribe to a side-channel
dmxNodeOutputBuskeyed by node id). - The existing
<SinkRateGauge nodeId={node.id} />.
dmx-in (source)
Inbound Art-Net listener. Subscribes to incoming Art-Net frames on a configured universe; emits an event onto the inputBus when a configured channel changes.
export interface DmxInSpec {
// Bind: 0.0.0.0 (LAN) or 127.0.0.1 (loopback for testing).
bindHost: string;
port: number; // typically 6454 for Art-Net
artnetUniverse: number; // 0..32767
// What to listen to. Single channel or contiguous range:
channel: number; // 1..512
// For range: emit one event per scanned channel update.
rangeLength?: number; // 1 (default) | up to 16
// Threshold for "this channel changed enough to fire" — drops jitter.
changeThreshold?: number; // 0..255, default 1
}
export interface DmxInNodeData {
id: string;
kind: "dmx-in";
label: string;
color: string;
spec: DmxInSpec;
transform?: InputTransform; // existing range / curve / deadzone
position: { x: number; y: number };
}
Listener
electron/dmx/artnetIn.ts opens a UDP socket on (bindHost, port), parses Art-Net OpDmx frames, and emits one IPC message per matching frame:
dmx:incoming { artnetUniverse, channelValues: number[512], remote, ts }
Refcounted per (bindHost, port) like osc-listener.ts.
The renderer hook src/features/inputs/useDmxInputs.ts reconciles dmx-in nodes against listener registrations and emits onto the inputBus when a watched channel's value changes by >= changeThreshold.
Signature shape (mirrors oscInSignature):
export function dmxInSignature(spec: DmxInSpec): string {
return dmx-in:${spec.bindHost}:${spec.port}:u${spec.artnetUniverse}:c${spec.channel};
}
Continuous values: each channel's 0..255 normalized to 0..1 before hitting the input bus. The existing applyTransform runs after.
Inspector
src/features/inspector/DmxInInspector.tsx. Sections: identity, listen (bind / port / Art-Net universe / channel / range length / change threshold), value transform (reuse <TransformEditor>), danger zone (delete). Same shape as OscInInspector.tsx.
Store changes
Add DmxOutNodeData and DmxInNodeData to AnyNode, the SinkNode / SourceNode discriminators, the knownKinds set in hydrateFromSnapshot and pasteGraph, and addDmxOutNode / addDmxInNode action creators (defaults: universe "1", fixture rgb, channel 1).
Settings:
interface Settings {
// ... existing
dmx: {
transmitters: TransmitterConfig[];
userProfiles: FixtureProfile[];
};
}
Topbar
Two new buttons next to "Serial Out". Lucide icons: Lightbulb for dmx-out, LightbulbOff or RadioReceiver for dmx-in.
What you'll write
src/features/dmx/
├── fixtures/ # see 02-fixtures.md
└── universeBus.ts # the contract object — Track A creates this; Track B uses it
src/features/mapping/
├── DmxOutNode.tsx # node body
└── (extend useMappingRouter.ts with fireDmxOutput)
src/features/inputs/
├── useDmxInputs.ts # reconciler for dmx-in nodes
src/features/inspector/
├── DmxOutInspector.tsx
├── DmxInInspector.tsx
├── DmxSettingsPanel.tsx # opened from the main SettingsPanel; manages transmitter list
electron/dmx/
├── artnetIn.ts # inbound Art-Net listener
├── (everything from 01-hardware-backends.md)
Don't do
- Don't read DMX values back to the renderer at frame rate. The diagnostics
dmx:statuspoll at 1 Hz is the only state push beyond write-acks. The node-body live swatch reads fromdmxNodeOutputBus(renderer-side, populated by the same write call that hits IPC) so we don't have a feedback hop. - Don't make
dmx-inevents go through the universe bus. They go directly to the inputBus, just likeosc-in. - Don't put a
serial-outand admx-outon the same COM port without thinking about it. The existingserial-bridge.tsopens a port for line-mode reading; opening it again as Open DMX clobbers the baud rate. Document this in the inspector if both nodes target the same path.