Multiplex¶
In Engine Box, you learned direct binding -- connecting a parameter to a widget with .ctrl_id := get_ui_id(Widget). When the parameter changes, the widget updates. When the user moves the widget, the parameter updates. It's automatic, and it works perfectly for 1:1 relationships: one volume knob, one volume parameter.
But consider a synthesizer with four sound layers, each with its own volume, pan, and tuning. You have one set of knobs on screen. When the user selects Layer A, the volume knob should show Layer A's volume. When they select Layer C, the same knob should flip to Layer C's volume. When they turn the knob, the value should write to whichever layer is currently selected.
Direct binding can't express this. .ctrl_id connects one parameter to one widget, permanently. There's no way to say "this widget points to Layer A's volume when the tab is here, and Layer C's volume when the tab is there."
This is where multiplex comes in -- a manual routing system where YOU decide which data slot a widget reads from and writes to. Instead of the engine automatically syncing a widget to a fixed parameter, you write two routing functions that the engine calls whenever data needs to move between the parameter layer and the UI layer.
When You Need Multiplex¶
Multiplex is the right tool when the relationship between a widget and a parameter is conditional or state-driven:
- Shared controls across layers -- one set of knobs, multiple layers of data, selected by a tab bar
- Shared controls across keys -- one set of sliders for 128 keyboard keys, selected by touching a key
- Mode-dependent routing -- one knob that controls sample offset in milliseconds OR sample offset as a percentage, depending on a toggle
In each case, the physical control stays the same. What it points to changes based on a selector -- a layer tab, a key selection, or a mode toggle.
Setting Up Multiplexing¶
Multiplexing requires three things: telling the engine which widgets are shared, telling it which parameters are multiplexed, and writing two routing functions.
Step 1: Register the Multiplex¶
In your UI binds file, call register_multiplex() to set up the shared widgets. This involves two helper functions:
listen_widget(ui_id) marks a UI control as multiplexed. When this widget is interacted with, the engine won't try to find a direct parameter binding -- instead, it'll call your multiplex.to_engine() function so you can route the event manually.
listen_par(par) marks a parameter as multiplexed. When this parameter's value changes, the engine won't try to push it to a directly-bound control -- instead, it'll call your multiplex.to_ui() function.
node LayerEngine.UIBinds:
cb Init:
layer_engine.register_multiplex()
cb Functions:
function layer_engine.register_multiplex()
{ Tell the engine these widgets are shared across layers }
layer_engine.listen_widget(get_ui_id(Main.VolumeKnob))
layer_engine.listen_widget(get_ui_id(Main.PanKnob))
layer_engine.listen_widget(get_ui_id(Main.TuneKnob))
{ Tell the engine these parameters use multiplex routing }
layer_engine.listen_par(layer_engine.par.VOLUME)
layer_engine.listen_par(layer_engine.par.PAN)
layer_engine.listen_par(layer_engine.par.TUNE)
end functionStep 2: Route Engine Data to UI¶
multiplex.to_ui(layer, par) is called whenever the engine needs to push a parameter value to the UI. You override this to route the value to the correct shared widget based on the current selector.
The engine provides write_to_ui(dims..., ui_id), which handles the range conversion and writes the engine value to a widget. Your job is just to tell it which widget to write to, and under what conditions:
function layer_engine.multiplex.to_ui(layer, par) override
{ Only update the widget if this is the currently selected layer }
if layer = selected_layer
select par
case layer_engine.par.VOLUME
layer_engine.write_to_ui(layer, layer_engine.par.VOLUME, ...
get_ui_id(Main.VolumeKnob))
case layer_engine.par.PAN
layer_engine.write_to_ui(layer, layer_engine.par.PAN, ...
get_ui_id(Main.PanKnob))
case layer_engine.par.TUNE
layer_engine.write_to_ui(layer, layer_engine.par.TUNE, ...
get_ui_id(Main.TuneKnob))
end select
end if
end functionThe selected_layer variable is yours to manage -- it tracks which layer tab the user has selected. When the engine fires this function for Layer B's volume but the user is looking at Layer A, you skip the UI update. When it fires for Layer A's volume and Layer A is selected, you push the value to the shared knob.
Step 3: Route UI Input Back to Engine¶
multiplex.to_engine(ui_id) is the reverse path. When the user moves a shared widget, this function is called so you can route the value to the correct parameter slot.
The engine provides read_from_ui(dims..., ui_id), which reads the widget value, converts it to engine range, and writes it to the specified parameter. Your job is to determine which layer and parameter the widget should target:
function layer_engine.multiplex.to_engine(ui_id) override
{ Route the UI value to the currently selected layer }
select ui_id
case get_ui_id(Main.VolumeKnob)
layer_engine.read_from_ui(selected_layer, layer_engine.par.VOLUME, ui_id)
case get_ui_id(Main.PanKnob)
layer_engine.read_from_ui(selected_layer, layer_engine.par.PAN, ui_id)
case get_ui_id(Main.TuneKnob)
layer_engine.read_from_ui(selected_layer, layer_engine.par.TUNE, ui_id)
end select
end functionWhen the user turns the volume knob while Layer C is selected, read_from_ui writes the knob's value into layer_engine[C].par[VOLUME], which triggers par_cb.response(C, VOLUME, value), which applies the volume change to Layer C's groups.
Refreshing After Selection Changes¶
When the user switches from Layer A to Layer C, every shared knob needs to snap to Layer C's values. That's what update_ctrls() does -- it iterates over every parameter in the engine and calls multiplex.to_ui() (or write_to_ui() for non-multiplexed parameters) to sync the UI.
Call it whenever the selector changes:
on ui_control(LayerSelector)
selected_layer := LayerSelector
layer_engine.update_ctrls()
end onYou'll also want to call it during reload, so the UI is correct after a preset load or snapshot restore. In the Polycore source, this lives in cb PostReload:
cb PostReload:
call layer_engine.push_data_id_to_ctrls()
call layer_engine.update_ctrls()The Data.PCCB() macro already calls update_ctrls() internally during cb Reload, but cb PostReload runs after all nodes have reloaded, making it a safe place for final UI sync.
State-Driven Display¶
The par_disp.response() function receives the extra dimension argument in a multi-dimensional engine. Combined with multiplex, this lets you show exactly what the user is adjusting -- not just the value, but the context.
In the Polycore production code, the display prefix uses a character array to label each layer:
function layer_engine.par_disp.response(layer, par, value, par_prefix_str, par_value_str)
par_prefix_str := f'Layer < char_up[layer] >'
select par
case layer_engine.par.VOLUME
par_value_str := f'< math.print_decimal(value, 4, 1) > dB'
case layer_engine.par.PAN
par_value_str := f'< value > %'
case layer_engine.par.TUNE
par_value_str := f'< value > st'
end select
end functionWhen the user turns the volume knob on Layer B, the display shows "Layer B: -6.0 dB". On Layer D, it shows "Layer D: 0.0 dB". The par_prefix_str provides context; the par_value_str provides the formatted value.
The Sylvan Pattern: Keys as the Multiplexed Dimension¶
Multiplexing isn't limited to sound layers. In Sylvan Phrases, each keyboard key is a dimension. The engine box phr.keys manages up to 128 key slots, each with its own sound assignment, volume, tuning, formant shift, startpoint, and endpoint:
define phr.keys.NUM_DIMS := 2
define phr.keys.DIMS := key, par
define phr.keys.MULTS := key
define phr.keys.SIZES := 128, 16
define phr.keys.par.MEMBERS := ...
sound_id, ...
volume, ...
tune, ...
formant, ...
startpoint, ...
endpointThe UI has one set of shared knobs -- Volume, Tune, Formant -- and a waveform display with startpoint/endpoint locators. When the user selects a different key on the keyboard, those controls snap to show that key's settings.
The multiplex registration uses a parameter mapping array to associate parameters with widgets, keeping the routing logic clean:
node Phr.KeymapData.UIBinds:
cb Init:
family phr
declare par_mapping[PHR.KEYS.PAR.SIZE] := (-1)
end family
phr.par_mapping[phr.keys.par.VOLUME] := get_ui_id(Main.Phrase.Volume)
phr.par_mapping[phr.keys.par.TUNE] := get_ui_id(Main.Phrase.Tune)
phr.par_mapping[phr.keys.par.FORMANT] := get_ui_id(Main.Phrase.Formant)
phr.par_mapping[phr.keys.par.STARTPOINT] := get_ui_id(Main.Wave.Locators)
phr.par_mapping[phr.keys.par.ENDPOINT] := get_ui_id(Main.Wave.Locators)
phr.keys.register_multiplex()Notice that STARTPOINT and ENDPOINT both map to the same Locators widget -- an XY pad with two cursors. The pad_idx property tells the engine which cursor each parameter controls:
function phr.keys.register_multiplex()
phr.keys.listen_widget(get_ui_id(Main.Phrase.Volume))
phr.keys.listen_widget(get_ui_id(Main.Phrase.Tune))
phr.keys.listen_widget(get_ui_id(Main.Phrase.Formant))
phr.keys.listen_widget(get_ui_id(Main.Wave.Locators))
phr.keys.listen_par(phr.keys.par.SOUND_ID)
phr.keys.listen_par(phr.keys.par.VOLUME)
phr.keys.listen_par(phr.keys.par.TUNE)
phr.keys.listen_par(phr.keys.par.FORMANT)
phr.keys.listen_par(phr.keys.par.STARTPOINT)
phr.keys.listen_par(phr.keys.par.ENDPOINT)
declare i
for i := 0 to phr.get_max_phrase_keys.const() - 1
phr.keys[i].startpoint.pad_idx := 0
phr.keys[i].endpoint.pad_idx := 2
end for
end functionThe to_ui override uses the mapping array to route parameter values. The selector here is phr.global.sel_key -- the currently selected keyboard key:
function phr.keys.multiplex.to_ui(key, par) override
if par = phr.keys.par.SOUND_ID
if key = phr.global.sel_key
call uir.update()
end if
end if
if key = phr.global.sel_key and phr.par_mapping[par] # -1
phr.keys.write_to_ui(key, par, phr.par_mapping[par])
end if
end functionIf the changed parameter has a widget mapping and it belongs to the currently selected key, push the value. If it's a SOUND_ID change for the selected key, also trigger a UI refresh (since the waveform display needs to update).
The to_engine override routes back using the same mapping:
function phr.keys.multiplex.to_engine(ui_id) override
declare key := phr.global.sel_key
if key > -1
declare par_idx := search(phr.par_mapping, ui_id)
if ui_id = get_ui_id(Main.Wave.Locators)
{ Locator widget: route based on which cursor moved }
if NI_CONTROL_PAR_IDX = 0
phr.keys.read_from_ui(key, phr.keys.par.STARTPOINT, ui_id)
else
phr.keys.read_from_ui(key, phr.keys.par.ENDPOINT, ui_id)
end if
else if par_idx # -1
phr.keys.read_from_ui(key, par_idx, ui_id)
end if
end if
end functionThe search() call finds which parameter maps to the widget that was touched, and read_from_ui() writes the converted value to the correct key slot.
The display function uses the key index to show which key is being edited:
function phr.keys.par_disp.response(key, par, value, par_prefix_str, par_value_str)
par_prefix_str := f'Key <midi.key_names[PHR.PHRASE_RANGE_MIN_KEY + key]>'
select par
case phr.keys.par.VOLUME
par_value_str := f'<math.print_decimal(value, 1, 1)> dB'
case phr.keys.par.TUNE
par_value_str := f'<math.print_decimal(value, 2, 1)> st'
case phr.keys.par.STARTPOINT
par_value_str := f'<math.print_decimal(value, 1, 1)> %'
end select
end functionThe prefix reads "Key C3" or "Key F#4" -- whatever key the user has selected.
The Polycore Pattern: Mode-Dependent Widget Routing¶
Polycore uses multiplex for a different purpose. Its plc.layers engine manages four layers with dozens of parameters. Most controls bind directly to per-layer widgets, but the sample offset knob is special -- it represents either sample offset in milliseconds or sample offset as a percentage, depending on a mode toggle.
This is a case where one widget maps to two different parameters. The to_ui override selects which parameter to display based on the current mode:
function plc.layers.multiplex.to_ui(layer, par) override
if par = plc.layers.par.SAMPLE_OFFSET and plc.layers[layer].sample_offset_mode = ON
plc.layers.write_to_ui(layer, plc.layers.par.SAMPLE_OFFSET, ...
plc.ui.sample_offset_knobs[layer])
end if
if par = plc.layers.par.SAMPLE_OFFSET_PCT and plc.layers[layer].sample_offset_mode = OFF
plc.layers.write_to_ui(layer, plc.layers.par.SAMPLE_OFFSET_PCT, ...
plc.ui.sample_offset_knobs[layer])
end if
end functionAnd the to_engine override routes the knob input to the correct parameter based on the current mode:
function plc.layers.multiplex.to_engine(ui_id) override
declare layer := search(plc.ui.sample_offset_knobs, ui_id)
if layer # -1
if plc.layers[layer].sample_offset_mode = ON
plc.layers.read_from_ui(layer, plc.layers.par.SAMPLE_OFFSET, ui_id)
else
plc.layers.read_from_ui(layer, plc.layers.par.SAMPLE_OFFSET_PCT, ui_id)
end if
end if
end functionThis shows that multiplexing isn't only about "one set of controls for N layers." It's a general mechanism for any case where the relationship between a widget and a parameter is conditional or dynamic.
The Complete Interaction Flow¶
Here's how all the pieces connect when the user interacts with a multiplexed instrument:
-
User selects Layer C via a tab or button. Your selector variable updates. You call
update_ctrls(). -
update_ctrls()loops through every parameter. For multiplexed parameters, it callsmultiplex.to_ui(). Your override checksif layer = selected_layer, finds it matches for Layer C, and callswrite_to_ui()to push Layer C's values into the shared knobs. -
User turns the volume knob. The UICBS system detects the widget's
custom_idmarks it as multiplexed, so it callsmultiplex.to_engine(ui_id). Your override routes the knob toread_from_ui(selected_layer, VOLUME, ui_id), which writes the value to Layer C's volume slot. -
par_cb.response(C, VOLUME, value)fires. You apply the volume to Layer C's sample groups. -
par_disp.response(C, VOLUME, value, ...)fires. You set the prefix to "Layer C" and format the value as decibels. The display updates. -
User loads a preset.
Data.PCCB()runs, which callsupdate_ctrls(), which firesmultiplex.to_ui()for every multiplexed parameter, syncing the knobs to the currently selected layer.
Multiplex is the alternative to direct binding. Direct binding is automatic and handles the common case -- one parameter, one widget, always connected. Multiplex is manual and handles everything else -- shared controls, conditional routing, state-driven display. Once you've written one to_ui/to_engine pair, the pattern is the same whether you're routing four layers or 128 keyboard keys.