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 functionThe 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 functionYou 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 functionFor 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 nodeset_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 nodeAdding 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 := 0In 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 selectAnd 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] := 500000The 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.