Skip to content

Engine Box


So far, we've built an instrument that plays sounds: voices travel through flows, visit nodes, and trigger PlayEvents. But a real instrument needs more than playback. It needs parameters -- volume knobs, attack sliders, preset selectors -- and those parameters need to stay in sync with the UI, survive preset loads, and respond to user interaction in real time.

You could manage all of this by hand -- declaring variables, binding them to widgets, writing save/load logic, formatting display text -- but that's a lot of boilerplate, and it's the same boilerplate every time. The engine box system handles all of it for you.

An engine box is a self-contained parameter engine. You declare your parameters, define their ranges and defaults, connect them to UI widgets, and write two response functions: one that reacts when a parameter changes, and one that formats its display text. The framework handles everything else -- serialization, UI sync, range validation, modulation offsets, and more.

The Three-File Pattern


In production instruments, an engine box is typically split across three files:

  • The data file -- declares the parameter engine, dimensions, and response functions
  • The meta file -- sets ranges, defaults, and enable flags for each parameter
  • The ui-binds file -- connects parameters to UI widgets

This separation keeps things organized as your parameter count grows. But let's start with just the data file and build up from there.

Declaring Parameters


Every engine box starts by defining a few configuration values: how many dimensions it has, what those dimensions are called, and what the individual parameters are named.

define my_engine.NUM_DIMS := 1
define my_engine.DIMS     := par
define my_engine.MULTS    := par
define my_engine.SIZES    := 8

define my_engine.par.MEMBERS := ...
    volume, ...
    attack

define my_engine.par.LONGNAMES := ...
    "Volume", ...
    "Attack"

Let's break this down:

NUM_DIMS is the number of dimensions in your parameter space. For a simple instrument with one set of parameters, this is 1.

DIMS lists the dimension names. With a single dimension, you just have par -- meaning you're addressing parameters by their parameter index alone.

MULTS lists all non-par dimensions (used for multi-dimensional addressing). With only one dimension, this is the same as DIMS.

SIZES sets the maximum number of parameters the engine can hold. It's good practice to oversize this slightly -- if you add parameters later, existing presets won't break.

par.MEMBERS lists the names of your parameters. These become constants you'll use to refer to each parameter throughout your code.

par.LONGNAMES provides display names for each parameter, used in automation and labels.

Creating the Engine


With the configuration defined, you create the engine inside a node's cb Init:

node MyEngine:
    cb Init:
        Data.InitializeDimension(my_engine, par)
        Data.CreateSingleDim(my_engine)

Data.InitializeDimension() builds the constant block for your parameter dimension. After this call, my_engine.par.volume and my_engine.par.attack are usable as integer constants.

Data.CreateSingleDim() creates the actual parameter storage, metadata arrays, UI binding infrastructure, and all the internal machinery the engine needs. For single-dimension engines (one set of parameters, no layers), this is the variant you'll use.

For instruments with multiple layers or articulations that each need their own parameter set, you'd use Data.CreateMultiDim() instead -- but that's a topic for more complex instruments.

Responding to Parameter Changes


When a parameter value changes -- whether from a UI interaction, a preset load, or a programmatic write -- the engine calls your par_cb.response() function. This is where you react to the new value.

    cb Functions:
        Data.Functions(my_engine)

        function my_engine.par_cb.response(par, value)
            select par
                case my_engine.par.VOLUME
                    { Apply volume to all groups }
                    declare i
                    for i := 0 to NUM_GROUPS - 1
                        set_engine_par(ENGINE_PAR_VOLUME, value, i, -1, -1)
                    end for
                case my_engine.par.ATTACK
                    { Apply attack time }
                    declare j
                    for j := 0 to NUM_GROUPS - 1
                        set_engine_par(ENGINE_PAR_ATTACK, value, j, -1, -1)
                    end for
            end select
        end function

The par argument tells you which parameter changed, and value is the new value. You use a select block to handle each parameter individually.

Data.Functions() generates all the internal functions the engine needs (validation, serialization, UI updates). You must call it inside cb Functions before your response functions.

Formatting Display Text


When the user interacts with a knob or slider, the instrument needs to show a human-readable value -- "75%", "200 ms", "On" -- not the raw internal integer. That's what par_disp.response() is for.

        function my_engine.par_disp.response(par, value, par_prefix_str, par_value_str)
            par_prefix_str := ""

            select par
                case my_engine.par.VOLUME
                    par_value_str := f'<math.print_decimal(value, 4, 1)> %'
                case my_engine.par.ATTACK
                    par_value_str := f'<value> ms'
            end select
        end function

You write into two string variables: par_prefix_str (an optional label prefix) and par_value_str (the formatted value). The engine uses these to update the display label bound to the parameter.

Serialization Hooks


The engine also requires two version-tracking functions, used during preset save/load:

        function my_engine.get_current_version() -> out
            out := 0
        end function

        function my_engine.check_version.response(version)
            { Handle version migration if needed }
        end function

For now, version 0 is fine. We'll cover serialization and version migration in the Serialization page.

The Reload Callback


To restore parameter state when a preset or snapshot loads, you call Data.PCCB() inside cb Reload:

    cb Reload:
        Data.PCCB(my_engine)

This single macro handles everything: version checking, range validation, firing par_cb for all parameters, and syncing UI controls. You'll almost never need to write custom reload logic for engine box parameters.

The UICBS Callback


To route UI control events to the engine, add Data.UICBS() inside cb UICBS:

    cb UICBS:
        Data.UICBS(my_engine)

This tells the framework to intercept ui_control events for any widget bound to this engine and route them through the parameter system.

The Meta File


Now we need to tell the engine the valid ranges and default values for each parameter. This goes in a separate node, typically in its own file:

node MyEngine.Meta:
    cb Init:
        util.array.fill(my_engine.par_enabled, TRUE)

        my_engine.set_range(my_engine.par.VOLUME, 0, 1000000)
        my_engine.set_range(my_engine.par.ATTACK, 0, 500000)

        my_engine.par[my_engine.par.VOLUME].default  := 750000
        my_engine.par[my_engine.par.ATTACK].default   := 0
end node

set_range() defines the minimum and maximum values for a parameter. These are the internal engine values -- the engine automatically maps UI widget positions to this range.

.default sets the value used when no preset is loaded, and what the "reset to default" action restores.

par_enabled controls which parameters are active. Setting it to TRUE for all parameters is the simplest starting point.

The UI Binds File


Finally, you connect parameters to UI widgets using .ctrl_id:

node MyEngine.UIBinds:
    cb Init:
        my_engine.volume.ctrl_id := get_ui_id(Main.VolumeKnob)
        my_engine.attack.ctrl_id := get_ui_id(Main.AttackKnob)

        my_engine.push_data_id_to_ctrls()
end node

.ctrl_id binds a parameter to a widget. When the parameter changes (from any source), the widget updates automatically. When the user moves the widget, the parameter updates automatically. The engine handles the range conversion between the widget's native range and your engine_min/engine_max.

push_data_id_to_ctrls() registers the engine's UICB routing so that widget interactions are dispatched through Data.UICBS().

Putting It All Together


Here's the complete three-file pattern for a simple engine with volume and attack:

{ === Data File === }

define my_engine.NUM_DIMS := 1
define my_engine.DIMS     := par
define my_engine.MULTS    := par
define my_engine.SIZES    := 8

define my_engine.par.MEMBERS := ...
    volume, ...
    attack

define my_engine.par.LONGNAMES := ...
    "Volume", ...
    "Attack"

node MyEngine:
    cb Init:
        Data.InitializeDimension(my_engine, par)
        Data.CreateSingleDim(my_engine)

    cb Reload:
        Data.PCCB(my_engine)

    cb UICBS:
        Data.UICBS(my_engine)

    cb Functions:
        Data.Functions(my_engine)

        function my_engine.par_cb.response(par, value)
            select par
                case my_engine.par.VOLUME
                    declare i
                    for i := 0 to NUM_GROUPS - 1
                        set_engine_par(ENGINE_PAR_VOLUME, value, i, -1, -1)
                    end for
                case my_engine.par.ATTACK
                    declare j
                    for j := 0 to NUM_GROUPS - 1
                        set_engine_par(ENGINE_PAR_ATTACK, value, j, -1, -1)
                    end for
            end select
        end function

        function my_engine.par_disp.response(par, value, par_prefix_str, par_value_str)
            par_prefix_str := ""

            select par
                case my_engine.par.VOLUME
                    par_value_str := f'<math.print_decimal(value, 4, 1)> %'
                case my_engine.par.ATTACK
                    par_value_str := f'<value> ms'
            end select
        end function

        function my_engine.get_current_version() -> out
            out := 0
        end function

        function my_engine.check_version.response(version)
        end function
end node
{ === Meta File === }

node MyEngine.Meta:
    cb Init:
        util.array.fill(my_engine.par_enabled, TRUE)

        my_engine.set_range(my_engine.par.VOLUME, 0, 1000000)
        my_engine.set_range(my_engine.par.ATTACK, 0, 500000)

        my_engine.par[my_engine.par.VOLUME].default := 750000
        my_engine.par[my_engine.par.ATTACK].default := 0
end node
{ === UI Binds File === }

node MyEngine.UIBinds:
    cb Init:
        my_engine.volume.ctrl_id := get_ui_id(Main.VolumeKnob)
        my_engine.attack.ctrl_id := get_ui_id(Main.AttackKnob)

        my_engine.push_data_id_to_ctrls()
end node

Adding a Mode Selector


Let's add a mode parameter -- a discrete selector (like an articulation or algorithm choice) rather than a continuous value:

define my_engine.par.MEMBERS := ...
    volume, ...
    attack, ...
    mode

define my_engine.par.LONGNAMES := ...
    "Volume", ...
    "Attack", ...
    "Mode"

In the meta file, the range defines the valid options:

my_engine.set_range(my_engine.par.MODE, 0, 2)
my_engine.par[my_engine.par.MODE].default := 0

In the display function, you format it as a name rather than a number:

case my_engine.par.MODE
    select value
        case 0
            par_value_str := "Normal"
        case 1
            par_value_str := "Bright"
        case 2
            par_value_str := "Dark"
    end select

And in the par_cb, you react to the mode change however your instrument needs:

case my_engine.par.MODE
    { Switch sample groups, update filters, etc. }

Reading and Writing Parameters


You can read and write parameter values from anywhere in your code:

{ Read the current volume }
declare vol := my_engine.par[my_engine.par.VOLUME]

{ Set a new volume, triggering par_cb and UI update }
my_engine.par[my_engine.par.VOLUME] := 500000

{ Set without triggering par_cb }
my_engine.par.raw[my_engine.par.VOLUME] := 500000

{ Public set -- triggers par_cb and display update }
my_engine.par.public[my_engine.par.VOLUME] := 500000

The par property is the standard read/write. The par.raw property bypasses the par_cb (useful during initialization). The par.public property triggers the display update, as if the user had moved the knob.


The engine box handles the vast majority of parameter management you'll need. For instruments with multiple layers or articulations that each need independent parameter sets, you can use multi-dimensional engines with Data.CreateMultiDim() -- but the pattern is the same, just with additional dimension indices. That's a topic for more complex instruments.