Skip to content

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() or ivls.play().
  • Cannot wait (ivls.wait_ms, ivls.wait_us, ivls.wait_ticks are unavailable).
  • Cannot have a cb NoteOff block.
  • 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 node

NoteOn / 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 node

Default 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 node

The 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 node

Three 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 if

After 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 via Voice[self].field.
  • nenv — index into the NodeEnv pool for execution context data.
  • thread — shorthand for Voice[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.

  • 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