The user's question, restated
> Can an input trigger multiple outputs at once, or must they always be in sequence? For something like DMX, we might want it in the chain to happen simultaneously with another node, or wait until after, or wait until the DMX finishes to go to the next node.
Today's behaviour
Looking at src/features/mapping/useMappingRouter.ts:181-206:
for (const e of graph.edges) {
const src = sourceById.get(e.source);
if (!src) continue;
const sink = graph.nodes.find((n) => n.id === e.target);
if (!sink) continue;
// …
if (sink.kind === "output") {
fireOscOutput(sink.id, sink.spec, !!evt.released, transformed);
} else if (sink.kind === "resolume-out") {
fireResolumeOutput(sink.id, sink.spec, !!evt.released, transformed);
}
}
fireOscOutput / fireResolumeOutput return Promise<void> but the loop does not await them. So:
- All sinks are kicked off in edge-array order.
- Each one's IPC roundtrip happens concurrently from JavaScript's perspective.
- The send order on the wire is determined by the main process:
osc:sendandresolume:sendare independent IPC channels backed by independent throttles, so OSC and WS can land in either order. Within OSC,node-oscflushes insendorder. Within WS,ws.sendis FIFO per socket.
- ✓ Fan-out works. Press one key → fire 10 OSC outs + 10 Resolume-outs. They all leave XOSC within ~1 ms.
- ✗ No way to say "send A before B."
- ✗ No way to say "send A, wait 50 ms, send B."
- ✗ No way to say "send A, then send B only if A succeeded."
Proposed dispatch-mode model (v0.5)
Add an optional dispatch field to MappingEdge:
export interface MappingEdge {
id: string;
source: string;
target: string;
// NEW (default "parallel" if absent):
dispatch?: {
mode: "parallel" | "sequential" | "after-prev";
// For sequential / after-prev: how long to wait after the previous edge fires.
delayMs?: number;
// For after-prev: skip this edge if the previous one's send returned an error.
requirePrevOk?: boolean;
// Sequence ordering: edges with the same source share a queue keyed by source-id;
// ties on order are resolved by edge insertion order.
order?: number;
};
}
Modes
- parallel (default) — current behaviour. All sinks for one input event start simultaneously.
- sequential — sinks for one input event start in
orderascending (then insertion order). The router awaits each sink's send before issuing the next, with optionaldelayMsbetween. - after-prev — like sequential, but each step also gates on
requirePrevOk. If the previous send returned{ ok: false }, this edge is skipped.
Router rewrite sketch
async function dispatchForEvent(evt, sources) {
// Group edges by source node; within each group, sort by dispatch.order.
for (const src of sources) {
const edges = graph.edges
.filter((e) => e.source === src.id)
.sort(byDispatchOrder);
const transformed = applyTransform(evt.value, src.transform);
let lastOk = true;
for (const e of edges) {
const mode = e.dispatch?.mode ?? "parallel";
if (mode === "after-prev" && e.dispatch?.requirePrevOk && !lastOk) continue;
if (e.dispatch?.delayMs) await sleep(e.dispatch.delayMs);
const sink = nodeById.get(e.target);
const send = fireSink(sink, evt, transformed);
if (mode === "parallel") { send.catch(() => {}); / fire and forget / }
else { lastOk = (await send).ok; }
}
}
}
This is roughly 40 LoC of change in useMappingRouter.ts plus a UI affordance on edges.
UI affordance
ColorMixEdge already supports custom data; we can add a small badge on the wire showing dispatch order (▶1, ▶2, …) and a different stroke for sequential edges (dashed) vs. parallel (solid). Edge inspector picks parallel | sequential | after-prev, sets order + delay.
DMX-specific concern: "wait until DMX finishes"
DMX universes refresh at most ~44 Hz (one full frame is ~22 ms over a serial DMX line). If we add DMX nodes (see dmx-feasibility.md), "wait until the DMX frame flushes" is a real semantics: you don't want to fire an OSC packet until the lighting state has actually been applied to the universe.
The proposed model handles this with mode: "sequential", delayMs: 25. The DMX sink resolves its promise once the frame is queued (not necessarily transmitted, but that's typically fine — the next 25 ms wait covers the actual transmit).
For full rigor we could add awaitFlush: true on a DMX edge and have the sink's promise resolve only after the universe's outbound buffer empties. Not necessary for v0.5; flag for v0.6.
What this doesn't address
- Multi-hop chains. Edges still go source-node → sink-node only. If you want sink-A's success to fire sink-B as a separate event, you currently can't. We'd need either:
See logic-nodes.md for the proposal — that's a separate v0.5 item.
- Cross-input synchronisation. "Wait until both MIDI knobs have moved before firing." Niche; not on the v0.5 list.
Migration considerations
MappingEdge.dispatchis optional; absent → "parallel" → behaviour is unchanged. Old configs hydrate cleanly.- The router rewrite must keep continuous-value semantics intact. Today the throttling is per-stream in the engine, so the renderer just kicks off
sendcalls; sequential mode interacts with that fine —await sendresolves once IPC returns, which is before the wire flush, but the engine still throttles.
Open questions
- Default
delayMsforsequential—0? Or1to make absolutely sure the OS scheduler interleaves? - Should
ordersurvive copy-paste? (Yes, since edge data includingorderis structured-cloned in the paste flow atstore.ts pasteGraph.) - For DMX — do we also need a cue-fade primitive, or does the existing transform/curve cover it? (Probably cue-fade is a sink-side concern; not router.)