← All docs
Ideas

dmx-out and dmx-in node kinds

> 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 dmxNodeOutputBus keyed 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:status poll at 1 Hz is the only state push beyond write-acks. The node-body live swatch reads from dmxNodeOutputBus (renderer-side, populated by the same write call that hits IPC) so we don't have a feedback hop.
  • Don't make dmx-in events go through the universe bus. They go directly to the inputBus, just like osc-in.
  • Don't put a serial-out and a dmx-out on the same COM port without thinking about it. The existing serial-bridge.ts opens 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.