Skip to content

UI: Per-Step Controls (Pass 2)

This pass adds a reusable knob form that binds to any single field in stepData for the currently selected step. One form definition covers all four per-step parameters — velocity, detune, filter, and wavetable position — by passing different macro values at instantiation.

Right-click patches -> main -> ui in the file tree and choose Create new Form. Name it step_knob.

Open the YAML editor for step_knob and enter:

# form: "step_knob"
width: 60
height: 80
controls:
knob:
anchors: { hCenter: 0, top: 20, width: 45, height: 45 }
type: knob
props:
displayMode: standard
onChangeEnd: on_change
bipolar: $bipolar
label:
anchors: { hCenter: 0, top: 0, width: 60, height: 20 }
type: text
props:
text: $title

The form is intentionally minimal: a text label anchored to the top, and a knob centred below it. Two macros are declared here:

  • $title — the caption shown above the knob; set by the parent form.
  • $bipolar — passed through to the knob’s bipolar prop so the embedding form can choose whether the knob sweeps 0–1 or −1 to +1.

Press Apply after entering the YAML.

step_knob form preview — label above, knob below

Open the script editor for step_knob and enter:

local steps
local stepSelected
local modTarget = sys_form_macros["$modTarget"]
function updateKnob()
if (not stepSelected) or (not steps) or not(modTarget) then
return
end
local step = steps[stepSelected]
local modValue = step[modTarget]
sys_modify_control("knob", "props", { value = modValue })
end
function stepsChanged(value)
steps = value
updateKnob()
end
function stepSelectedChanged(step)
stepSelected = step
updateKnob()
end
stepsChanged(sys_subscribe_to_parameter("stepData", stepsChanged))
stepSelectedChanged(sys_subscribe_to_parameter("stepSelected", stepSelectedChanged))
function on_change(ctrlName, value)
if (not stepSelected) or (not steps) or not(modTarget) then
return
end
local step = steps[stepSelected]
step[modTarget] = value
sys_set_parameter_value("stepData", steps)
end

Press Apply after entering the script.

$modTarget is the third macro — read from sys_form_macros in the script rather than declared in the YAML, because it isn’t a UI property; it’s pure script logic. It holds the field name to read and write in stepData (e.g. "velocity", "tune", "filter", "wtPos").

At load time the script subscribes to both stepData and stepSelected. Either subscription firing calls updateKnob, which reads steps[stepSelected][modTarget] and pushes the value to the knob via sys_modify_control — keeping the display in sync whenever the user clicks a different step or another form edits the data.

When the user moves the knob, on_change writes the new value back into the local steps table at the correct field and publishes the updated table to stepData. Because sys_set_parameter_value does not fire the calling form’s own subscription callback, the local steps copy is already up to date — no manual updateKnob() call is needed here.

step_controls composes the four step_knob instances into a single panel and adds the batch-modify controls backed by the parameters added in Global Parameters Pass 2.

Right-click patches -> main -> ui and choose Create new Form. Name it step_controls.

# form: "step_controls"
width: 640
height: 82
controls:
bg.image:
anchors: { left: 0, top: 0, right: 0, bottom: 0 }
type: image
props:
image: "step_controls_bg.svg"
velo_knob:
pos: { x: 5, y: 3, w: 60, h: 80 }
type: subForm
props:
form: step_knob
macroParameters: { $modTarget: velocity, $title: VELO }
tune_knob:
pos: { x: 85, y: 3, w: 60, h: 80 }
type: subForm
props:
form: step_knob
macroParameters: { $modTarget: tune, $title: DETUNE, $bipolar: "true" }
filter_knob:
pos: { x: 165, y: 3, w: 60, h: 80 }
type: subForm
props:
form: step_knob
macroParameters: { $modTarget: filter, $title: FILTER }
wt_knob:
pos: { x: 245, y: 3, w: 60, h: 80 }
type: subForm
props:
form: step_knob
macroParameters: { $modTarget: wtPos, $title: SOUND }
batchChangeTarget:
pos: { x: 359, y: 40, w: 85, h: 30 }
type: menuButton
props:
parameterUid: "batchChangeTarget"
entries:
- VELO
- DETUNE
- FILTER
- SOUND
randomize:
pos: { x: 461, y: 40, w: 85, h: 30 }
type: button
props:
text: RANDOMIZE
onPressed: on_randomize
rand_fader:
pos: { x: 461, y: 9, w: 85, h: 25 }
type: fader
props:
orientation: horizontal
parameterUid: randomizeRange
caption:
pos: { x: 349, y: 8, w: 100, h: 30 }
type: text
props:
text: "MODIFY ALL:"
reset_button:
pos: { x: 559, y: 40, w: 70, h: 30 }
type: button
props:
text: "DEFAULTS"
onPressed: on_set_defaults

The four step_knob subforms sit on the left. Each passes a different $modTarget field name and a human-readable $title; the detune knob also sets $bipolar: "true" so it sweeps −1 to +1 instead of 0 to 1.

The right-hand section is the batch-modify panel. batchChangeTarget and rand_fader use parameterUid to wire directly to their global parameters — no script subscription needed for those two controls; the platform keeps them in sync automatically.

Press Apply after entering the YAML.

step_controls form preview — four knobs left, batch controls right

local batchChangeTarget
local randomizeRange
function batchChangeTargetChanged(value)
batchChangeTarget = value
end
function randomizeRangeChanged(value)
randomizeRange = value
end
batchChangeTargetChanged(sys_subscribe_to_parameter("batchChangeTarget", batchChangeTargetChanged))
randomizeRangeChanged(sys_subscribe_to_parameter("randomizeRange", randomizeRangeChanged))
local steps
steps = sys_subscribe_to_parameter("stepData", function(x) steps = x end)
function clamp0to1(n)
return math.min(math.max(n, 0), 1)
end
function on_randomize(ctrlName)
for i = 1, 16 do
local step = steps[i]
local diff = math.random() * (2 * randomizeRange) - randomizeRange
if batchChangeTarget == 0 then
step.velocity = clamp0to1(step.velocity + diff)
elseif batchChangeTarget == 1 then
step.tune = clamp0to1(step.tune + diff)
elseif batchChangeTarget == 2 then
step.filter = clamp0to1(step.filter + diff)
elseif batchChangeTarget == 3 then
step.wtPos = clamp0to1(step.wtPos + diff)
end
end
sys_set_parameter_value("stepData", steps)
end
function on_set_defaults(ctrlName)
for i = 1, 16 do
local step = steps[i]
if batchChangeTarget == 0 then
step.velocity = 0.8
elseif batchChangeTarget == 1 then
step.tune = 0.5
elseif batchChangeTarget == 2 then
step.filter = 0.2
elseif batchChangeTarget == 3 then
step.wtPos = 0.5
end
end
sys_set_parameter_value("stepData", steps)
end

Press Apply after entering the script.

Target selectionbatchChangeTarget is a 0-based integer matching the order of the menuButton entries (0 = VELO, 1 = DETUNE, 2 = FILTER, 3 = SOUND). The menuButton writes it directly via parameterUid; the script subscribes to read it for the two button handlers.

Randomizeon_randomize iterates all 16 steps and adds a random offset to the selected field. The offset is drawn from the range [−randomizeRange, +randomizeRange], then clamped to 0–1 so no step goes out of bounds. The updated table is published in one sys_set_parameter_value call, which notifies all subscribed forms (including the four step_knob instances) in one shot.

Defaultson_set_defaults resets the selected field to its original default value across all 16 steps. The default values match those used in the stepData generator in Global Parameters Pass 1.

stepData subscription — unlike the named callbacks for the other two parameters, stepData uses an inline anonymous function. This form only ever needs the latest snapshot of steps to operate on; it never needs to react visually to data changes, so a one-liner is sufficient.

With several knobs now visible in the editor, this is the right moment to apply the project’s global style sheet. The style sheet configures the default appearance of every standard control — knob graphics, colours, fonts — in one shot. Applying it now lets you see the effect immediately: all knobs on screen will update at once, which is the whole point of a global style sheet.

Open the Style Editor from the editor’s main menu.

Style Editor open

Click Import and select the style sheet file from the unpacked asset bundle. The editor applies it immediately and every knob in the project switches to the bundled look simultaneously.

All knobs updated after style sheet import

Open the main form’s YAML. Add the stepControls entry inside the existing controls: block, at the same indentation level as steps and keyboard:

width: 700
height: 560
controls:
bg_image:
anchors: { left: 0, top: 0, right: 0, bottom: 0 }
type: image
props:
image: plugin_bg.png
steps:
pos: { x: 30, y: 63, w: 640, h: 72 }
type: subForm
props:
form: step_grid
stepControls:
pos: { x: 30, y: 155, w: 640, h: 82 }
type: subForm
props:
form: step_controls
keyboard:
pos: { x: 5, y: 423, w: 690, h: 130 }
type: subForm
props:
form: keyboard

Press Apply. The per-step knob panel appears between the step grid and the keyboard.

Main form with step grid, step_controls panel, and keyboard

The instrument is now nearly complete. Each step has independently editable velocity, detune, filter, and wavetable position — all reflected live in the knob panel as you click through steps. The most dramatic way to verify everything is working is with the randomiser:

  1. Start playback by holding any key.
  2. In the MODIFY ALL panel, open the target menu and select FILTER.
  3. Drag the range fader to around 50%.
  4. Hit RANDOMIZE.

The filter envelope depth will scatter across all 16 steps immediately. You should hear the character of each note change on the fly — some steps hitting fully open, others barely moving the filter. Hit DEFAULTS to reset the filter back to a uniform value across all steps, then try the same with SOUND (wavetable position) for a more timbral variation.

  • Global controls panel (volume, filter, delay, reverb)