← All docs
Ideas

Math & logic nodes (proposal)

> Proposal for v0.5 backlog item #4. Read architecture-limits.md and dispatch-semantics.md first.

Why

Every "above and beyond" feature the outside agents suggested ("hold for 500 ms = different OSC", "scale 0..1 → 0.3..0.8", "double-tap fires X") is the same mechanical primitive: a node that consumes an event from the inputBus and emits a (possibly different) event back onto it. Today XOSC has no such thing — sources and sinks are disjoint.

The shape

A new node kind, transform (working title; could be logic, mid, etc.):

export type TransformOpKind =
  | "clamp" | "scale" | "invert" | "curve"
  | "hold-timer" | "double-tap" | "threshold" | "latch" | "edge";

export interface TransformNodeData {
id: string;
kind: "transform";
label: string;
color: string;
position: { x: number; y: number };
op: TransformOp; // tagged-union per op kind, see below
}

export type TransformOp =
| { kind: "clamp"; min: number; max: number }
| { kind: "scale"; inMin: number; inMax: number; outMin: number; outMax: number }
| { kind: "invert" } // 1-v for continuous; !b for discrete
| { kind: "curve"; gamma: number } // wraps applyTransform
| { kind: "hold-timer"; thresholdMs: number } // emits "tap" or "hold"
| { kind: "double-tap"; windowMs: number } // emits on second press
| { kind: "threshold"; up: number; down: number } // hysteresis
| { kind: "latch" } // toggle on each pulse
| { kind: "edge"; mode: "rise" | "fall" | "both" };

A transform node has:

  • One input handle on the left (sink-like — receives events).
  • 1–N output handles on the right (source-like — emits events). Most ops have 1 output. hold-timer has 2 (tap, hold). edge has 1 each for rise/fall (or one combined). threshold has 2 (high, low).

Edges that touch transform nodes need a handleId. Today's MappingEdge has only source and target; we'd add sourceHandle?: string and targetHandle?: string. ReactFlow already supports per-handle ids natively.

Router rewrite

The router currently iterates graph.edges flat. With transform nodes, an event at a source needs to reach every reachable sink, traversing transform nodes along the way. The clean approach:

1. Build a per-source "reachable sinks" set on graph mutation, with the transform path remembered. Cache invalidated on edge/node add/remove.
2. On inputBus.emit, for each matching source, walk the cached paths. Apply each transform's op to the value. When we reach a real sink, fire it.

A path is a sequence: source → [transform op, ...] → sink. The renderer can compute these once per graph change; cheap.

Pseudo:

// Cached at graph-change time.
type Path = {
  sourceId: string;
  sinkId: string;
  steps: TransformStep[];   // ordered op evaluation chain
  edges: string[];          // for debugging / dispatch ordering
};

function dispatch(evt) {
for (const src of matchingSources) {
for (const path of pathsFromSource[src.id]) {
let v = evt.value !== undefined ? applyTransform(evt.value, src.transform) : undefined;
let released = !!evt.released;
let drop = false;
for (const step of path.steps) {
const out = applyOp(step.op, v, released);
if (out === DROP) { drop = true; break; }
v = out.value;
released = out.released;
}
if (!drop) fireSink(path.sinkId, v, released);
}
}
}

Each op's applyOp is pure for value-transforms (clamp / scale / invert / curve) and stateful for time/event-based ops (hold-timer, double-tap, latch). Stateful ops keep their state in a Map keyed by (transformNodeId, sourceId) — separate state per source so two inputs feeding the same transform node don't collide.

UI

  • New TransformNode.tsx renders a compact node. Body shows the op kind + a one-line summary of params (clamp 0..0.8, scale -1..1 → 0..1, hold ≥ 500 ms).
  • Inspector for transform nodes: pick op kind from a dropdown; param fields appear based on the op.
  • Multi-output ops (hold-timer, threshold, edge) show two right-side handles with small labels.
  • Color picker, label, color stripe — same affordances as other nodes.

Stateful op semantics (the tricky ones)

hold-timer

  • On press → start timer.
  • On release before thresholdMs → emit "tap" pulse.
  • On thresholdMs elapsed without release → emit "hold" pulse, mark as in-hold.
  • On release after threshold → emit "hold" release.
State per (nodeId, sourceId):
{ pressedAt: number | null; emittedHold: boolean; timerId: number | null }

Drop the timer on graph deletion.

double-tap

  • On press → record lastPressAt.
  • If next press within windowMs → emit pulse.
  • Single press inside the window does nothing visible (gets eaten).
State: { lastPressAt: number | null }.

threshold (hysteresis)

For continuous values:

  • If value > up and currently low → emit "high" rising-edge, mark high.
  • If value < down and currently high → emit "low" falling-edge, mark low.
  • Otherwise no emit.

State: { high: boolean }.

latch

  • Each press toggles internal bool, emits 1/0 alternately.
State: { on: boolean }. Same as the existing per-sink toggleState but one hop earlier.

Why this is worth it (vs. shipping more sink-side flags)

  • Composability. Today the toggle/hold/pulse switch lives on each sink spec. Three sinks each want hold? Three configs. With a transform node, one hold-timer feeds three sinks.
  • Visibility. Behaviour you can see in the graph beats behaviour buried in inspector tabs.
  • Foundation for v0.6+. A latch + threshold + double-tap covers 90 % of "I want this gesture to fire that thing" requests we'll get from event techs.

Risks

  • Cycle creation. A transform node is both source and sink, so a user can wire one back into itself (or into a chain that loops). Solved by the lineage-cap from v0.5 backlog #1.
  • Performance with deep chains. 60 Hz axis through a chain of 5 transforms = 300 op-evals/sec. Fine. We'd start to care above ~5000/sec; nobody will hit that with this UI.
  • Persistence across hydration. Transform nodes serialize cleanly via the existing toSnapshotData. Stateful ops reset on hydration — that's the right default.

Estimate

L (most-of-a-day) for the first set of pure ops (clamp / scale / invert / curve) plus the router rewrite to support multi-hop dispatch. +M for the stateful ops (hold-timer, double-tap, threshold, latch, edge).

Land the pure ops first; ship the stateful ones in a follow-up patch.