Skip to content

Modrix

Modrix is a self-contained modulation matrix that can be added to any IVLS product. It supplies normalized modulator outputs, routes them to product parameters with depth and curve transforms, and keeps the UI in sync through a processing loop that runs on CC events and on a 5 ms LCB timer. The most complete implementation is in Super Audio Cart 2, which combines Polycore and Modrix.

Why Modrix Exists

Standard MIDI modulation (LFO, CC mapping) is hard to extend, impossible to introspect, and disconnected from the instrument's own parameter system. Modrix replaces this with a product-aware matrix: modulators know about the product's parameters, depths are bounded by each parameter's actual range, and the whole system is serialized and managed through the engine-box infrastructure.

Key Concepts

The Three Concepts

Modulators are sources of normalized signal, outputting floats in [-1.0, 1.0] (bipolar) or [0.0, 1.0] (unipolar). Built-in types: LFO, AHDSR, CC, Velocity, Random, Stepper, Pitch Bend, Mono AT, Key Range, Vel Range, Keyswitch, Macros, XY. Custom modulator types can be added.

Routings connect one modulator to one parameter. Each routing stores a modulator index, depth, curve, bounded mode flag, and a four-integer route vector encoding the target system and parameter.

Systems are participating engines that own parameters. Polycore, Console, and custom product systems are all "systems" in Modrix terms. Each system implements a 16-function API (9 required, 7 optional).

Route Encoding

Each routing stores a four-integer vector:

Index Meaning
0 System index (modrix.system.POLYCORE, etc.)
1 Dimension 1 (e.g. layer index)
2 Dimension 2 (e.g. parameter index)
3 Dimension 3 (unused or -1 for most systems)

"Fake" values are intentional: if the max real layer index is 3, the value Polycore.LAYERS (4) signals "apply to all layers." The system's modulate function must handle this, and the browser must write the same fake value when the user selects "All Layers."

The 16-Function System API

Each system must implement 9 required functions and may implement 7 optional functions:

Required (9):

Function Purpose
get_control_min(routing) -> min Engine minimum
get_control_max(routing) -> max Engine maximum
get_control_base(routing) -> base Current parameter value
get_routing_name(routing) -> name Display name
get_routing_category(routing) -> cat Display category
modulate(routing, arg) Apply computed offset to the parameter
base_from_proxy(routing, ui_id) Widget value → engine
base_to_proxy(routing, ui_id) Engine value → widget
set_base_default(routing, ui_id) Reset widget to default

Optional (7):

Function Purpose
routing_created(routing) Called when a new routing is created
routing_deleted(routing) Called when a routing is deleted
dismiss_destination(routing) Dismiss/cleanup for a destination
xy_to_system(routing, x, y) XY pad forwarding
get_routing_key(routing) -> key MIDI key scoping
get_routing_compat(routing) -> compat Compatibility check
get_routing_thread(routing) -> thread Thread assignment

The api.ksp template generates all dispatch wrappers automatically from the Python data model.

The Processing Loop

Modrix runs on every CC event and on a 5 ms LCB timer:

cb CC:
    call modrix.run_matrix()

cb LCB:
    if modrix.cache.active_mods.count > 0
    and abs(ENGINE_UPTIME - modrix.last_lcb_run) >= 5
        modrix.run_matrix()
        modrix.last_lcb_run := ENGINE_UPTIME
    end if

The active_mods.count > 0 guard skips the matrix entirely when nothing is routed. The LCB guard prevents back-to-back runs on fast hosts.

modrix.run_matrix() proceeds in three phases:

  1. Read modulator values: For every active modulator, call tick(m) to update phase/state, then process(m) -> out to read normalized output.
  2. Map routings: Routings are pre-sorted by destination. For each destination group, sum ADD-style routings and multiply SCALE-style routings.
  3. Bind to controls: Compute scaled modulation and apply via system.modulate().

Bounded Mode

In Bounded OFF mode, the offset is applied as a blanket addition clamped to the control's range.

In Bounded ON mode, the offset scales asymmetrically: negative offsets scale against the distance from base to min, positive offsets against the distance from base to max. With a base at 70%, a -100% modulation produces -70%, not -100%, because it scales within the 70-unit left span.

Caching and Invalidation

modrix.recache() runs whenever the routing matrix changes. It validates routings, sorts them by destination, builds the active modulator list, and fires __RUN_CB__(Modrix.RefreshCache). The sort-by-destination is what enables O(R) processing instead of O(R×D).

Polycore uses cb Modrix.RefreshCache to re-analyze which parameters are actively routed and clear stale mod_offset values on unrouted parameters, preventing ghost modulation from accumulating when routings are deleted.

Console Sync Pattern

When Modrix and Console both control the same parameter, a directional protocol prevents feedback loops: - Moving a Modrix knob → write to Console knob and run its callback - Moving a Console knob → silently write to the Modrix engine without triggering par_cb

Python Package

Modrix is a pip-installable Python package. Product data is defined in Python and KSP code is generated via Jinja templates:

import modrix
mx = modrix.create([...modulators...], nka_data_dir)
mx.add_system('polycore')
mx.add_system('console')
code = modrix.emit(mx)

modrix.create() builds the data model and writes a _meta.nka file containing engine_min, engine_max, default, moddable, random_min, and random_max for every modulator-parameter pair.

Connections to Other Parts of IVLS

Modrix routing objects are pool-allocated using the type-system. Cache lists use the array-backed structures from data-structures. The engine-box provides the parameter ranges and moddable flags that Modrix reads. The vmc system is used for applying modulation to Kontakt parameters on active sound voices.

Patterns and Caveats

  • The browser must write routing destination values using the same encoding that modulate() reads. Browser columns are not a separate display system — they write directly into routing storage.
  • Systems are registered by calling mx.add_system('name') in Python. Order of registration determines the system constant index. Changing order breaks existing saved routings.
  • The LFO's rate parameter maps to frequency Hz via an exponential curve spanning sub-0.1 Hz to audio rate. The coefficient range is RATE_COEF_MIN := -4.5639 to RATE_COEF_MAX := 3.9121.
  • type-system — routing and modulator objects are pool-allocated types
  • data-structures — cache lists use array-backed structures
  • engine-box — parameter ranges, defaults, and moddable flags