Skip to content

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 function

Step 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 function

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

When 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 on

You'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 function

When 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, ...
    endpoint

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

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

If 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 function

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

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

And 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 function

This 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:

  1. User selects Layer C via a tab or button. Your selector variable updates. You call update_ctrls().

  2. update_ctrls() loops through every parameter. For multiplexed parameters, it calls multiplex.to_ui(). Your override checks if layer = selected_layer, finds it matches for Layer C, and calls write_to_ui() to push Layer C's values into the shared knobs.

  3. User turns the volume knob. The UICBS system detects the widget's custom_id marks it as multiplexed, so it calls multiplex.to_engine(ui_id). Your override routes the knob to read_from_ui(selected_layer, VOLUME, ui_id), which writes the value to Layer C's volume slot.

  4. par_cb.response(C, VOLUME, value) fires. You apply the volume to Layer C's sample groups.

  5. par_disp.response(C, VOLUME, value, ...) fires. You set the prefix to "Layer C" and format the value as decibels. The display updates.

  6. User loads a preset. Data.PCCB() runs, which calls update_ctrls(), which fires multiplex.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.