> 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-timerhas 2 (tap,hold).edgehas 1 each for rise/fall (or one combined).thresholdhas 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.tsxrenders 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
thresholdMselapsed without release → emit "hold" pulse, mark as in-hold. - On release after threshold → emit "hold" release.
(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).
{ lastPressAt: number | null }.
threshold (hysteresis)
For continuous values:
- If
value > upand currently low → emit "high" rising-edge, mark high. - If
value < downand currently high → emit "low" falling-edge, mark low. - Otherwise no emit.
State:
{ high: boolean }.
latch
- Each press toggles internal bool, emits 1/0 alternately.
{ 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.