Nodes¶
A node is the fundamental unit of instrument logic in IVLS. Each node declares one or more named callback blocks — cb NoteOn, cb NotePass, cb NoteOff, cb Init, and others — and is assembled at compile time into ordered processing sequences. There is no runtime dispatch table. The preprocessor statically inlines every node's callback body into the correct expansion, so a node that does not declare a given callback has zero cost in that expansion.
The key mental model is that a node is a module, not an object. The same node can be registered into multiple flows with different arguments, changing its behavior in each context. And nodes compose — a flow is simply a sequence of nodes, and voices traverse that sequence one stage at a time.
Why Nodes Exist¶
Before nodes, large instruments accumulated voice processing logic in monolithic on note handlers that were hard to reuse, test, or maintain. Nodes solve this by making each concern (gating, routing, articulation evaluation, sample triggering) its own named unit with clear inputs and outputs. The preprocessor's __RUN_CB__ expansion means that including a node in a flow costs exactly the inlined code — nothing more.
The Three Callback Types¶
The most important design decision for any node is which callback type to use for voice processing.
NotePass¶
Synchronous and inline. The voice is processed directly in the dispatch loop without spawning a new taskfunc. This is the default choice and the cheapest option.
- Cannot call
ivls.pass()orivls.play(). - Cannot wait (
ivls.wait_ms,ivls.wait_us,ivls.wait_ticksare unavailable). - Cannot have a
cb NoteOffblock. - Voice advances automatically after the callback returns.
node Tokyo.Modify.SmartAttack:
cb NotePass:
Voice[self].vel := mf.compute_attack_vel(Voice[self].vel)
ivls.reflow_voice(self, some_flow) // routing is fine
end nodeNoteOn / NoteOff¶
Asynchronous, runs in a taskfunc context. Required for:
- Time-based behavior (ivls.wait_ms, ivls.wait_us)
- Key-release response (cb NoteOff)
- Calling ivls.pass() to advance the voice to the next stage
node Stl.MonoLegato:
cb NoteOn:
ivls.wait_ms(legato_window_ms)
if path_on
ivls.pass()
end if
cb NoteOff:
// cleanup on key release
end nodeDefault to NotePass. Only promote to NoteOn when you genuinely need async behavior, waits, or NoteOff.
Naming Conventions¶
Node names follow a Product.Verb.Feature pattern. The verb signals the behavioral role:
| Verb | Purpose |
|---|---|
Modify |
Transforms voice fields — note, velocity, volume, articulation data |
Divert |
Routes voice to a different flow — pure branching, no other logic |
Spawn |
Creates child voices for layers, release noise, overlays |
Sys |
Records state without producing sound |
Play |
Triggers a Kontakt sound event |
Gate |
Stops voices that fail a condition |
Examples from production: Tokyo.Modify.SmartAttack, Tokyo.Divert.EngineStyle, TACT.Spawn.Artic, Polycore.Gate.Ranges, Polycore.Sys.CurrentNote.
Declaring Nodes¶
node MyProduct.SomeFeature:
cb Init:
family mf
declare some_state := 0
end family
cb NoteOn:
// self = Voice index, nenv = NodeEnv index
ivls.pass()
cb NoteOff:
// runs on key release
cb Functions:
function mf.compute_thing(x) -> result
result := x * 2
end function
end nodeThe cb blocks are containers. If a node omits cb Init, it simply has no presence in the Init expansion. __RUN_CB__(Init) expands only into calls to nodes that declared that callback.
Node Bundles¶
Group related nodes into a named bundle using +=:
define Polycore.PlaybackFeatures += Polycore.Flows
define Polycore.PlaybackFeatures += Polycore.Keymap.DefaultPressedState
define Polycore.PlaybackFeatures += Polycore.Sys.CurrentNote
define Polycore.PlaybackFeatures += Polycore.Divert.PlayModes
define IVLS_NODES := Polycore.PlaybackFeatures, Polycore.OtherFeatures, ...Bundles accumulate across files via +=. Each contributing file appends its members to the same define — so bundle membership can be co-located with the nodes themselves.
Node Inheritance¶
A node can extend a template with from:
node Polycore.PlayEvent from Stl.PlayEvent.Template:
cb EventArgs:
event.note := Voice[event_vo].note
event.vel := plc.get_sound_trigger_info(sound_id, plc.trigger.VEL)
end nodeThree control keywords govern parent/child interaction:
| Keyword | Effect |
|---|---|
__VIRTUAL__(CbName) |
Child's cb CbName replaces the parent's version entirely |
__ALWAYS__ |
Marks a parent block that always runs even when the child overrides |
__PARENT__ |
Injection point where child code is inserted inside the parent body |
One level of inheritance only — node C from B from A is not supported.
Node Arguments¶
The same node can be registered into multiple flows with different arguments, varying its behavior per registration:
// Register with arguments
cb Flows:
ivls.register_node(tky.flows.play_sound, MyNode)
ivls.register_arg(tky.flows.play_sound, 0, MODE_LEGATO)
ivls.register_arg(tky.flows.play_sound, 1, 500)
// Read inside the node
cb NoteOn:
declare mode := node_arg(0)
declare timeout := node_arg(1)node_arg(n) expands to the flow's argument table at the current stage. For typed, named arguments use ivls.MakeRegister(MyNode) and ivls.MakeArgs(MyNode).
The Async Execution Model¶
Voice-flow callbacks run in taskfunc context, not directly in on note. The cluster system bridges KSP's synchronous callbacks to taskfuncs by firing synthetic note events whose on release triggers dispatch for queued node functions. Each node callback runs in its own named taskfunc.
The path_on define checks voice validity after any wait:
cb NoteOn:
ivls.wait_ms(50)
if path_on
ivls.pass()
end ifAfter a safe wait, if the voice died during the delay, PathCancellation fires and the taskfunc stops. Unsafe variants (ivls.wait_ms.unsafe) skip this check — use them only when you own the voice's lifetime and know it cannot die during the wait.
self and nenv¶
Inside any voice-flow callback:
self— index into the Voice pool for the currently processing voice. Read/write fields viaVoice[self].field.nenv— index into the NodeEnv pool for execution context data.thread— shorthand forVoice[self].thread.
How It Connects to Other Parts of IVLS¶
Nodes do not exist in isolation — they need to be registered into flows and traversed by voices. The cb Flows callback is where a node declares and populates flows. The cb Keymaps callback is where a node maps keys to those flows via the keymaps system. Node callbacks that affect modulation interact with the vmc tree through Modbit operations.
Patterns and Caveats¶
The Divert pattern: Routing logic belongs in Divert nodes, not algorithm nodes. A Modify node should never call ivls.reflow_voice. This separation keeps algorithm nodes simple and reusable across flows.
NotePass cannot pass: In a NotePass node, do not call ivls.pass(). The voice advances automatically. Calling pass in NotePass is a logic error.
cb Flows runs once: It runs inside FirstLoad, not on every reload. Flow topology is fixed at first load and does not change. Dynamic routing happens at runtime via ivls.reflow_voice, not by modifying flow registrations.
Related¶
- flows — how nodes are assembled into ordered processing pipelines
- voices — the Voice struct, lifecycle, and how voices traverse flows
- ../language-reference/nodes — complete callback name reference and
__RUN_CB__dispatch table