Skip to content

Lua Scripting

Each form can have an optional Lua script. Scripts react to parameter changes and control events, and can modify control properties at runtime. Use scripting when parameter bindings alone are not expressive enough.

Script-Driven vs. Parameter-Bound Controls

Section titled “Script-Driven vs. Parameter-Bound Controls”

Most interactive controls (knob, fader, toggle, buttonSelector) can work in two modes:

  • Parameter-bound — set parameterUid in props and the control syncs automatically. Preferred when possible.
  • Script-driven — omit parameterUid; set an initial value and attach a handler function via the appropriate props field (e.g. onChange).

The handler value in props is a string matching the name of a top-level function in the form’s Lua script:

# In the .frm YAML
mode_toggle:
type: toggle
pos: { x: 8, y: 8, w: 48, h: 24 }
props:
value: 0
onChange: on_mode_changed
-- In the .frm Lua script
function on_mode_changed(controlName, value)
-- controlName: the control's ID; value: new toggle state (false or true)
sys_modify_control("detail_panel", "", {visible = value})
end

Every control callback receives the control’s ID as its first argument — always, including callbacks with no further payload. This is a strict convention. Its main benefit is that you can wire the same function to multiple controls and dispatch on the caller:

function on_any_toggle(controlName, value)
sys_log(controlName .. " changed to " .. tostring(value))
-- react differently depending on which control fired
end

button controls use onPressed instead of onChange; the handler receives only the control name (no value argument).

In the form editor, click Edit Script. This opens the Lua editor for the current form. Each .frm file has exactly one script, scoped to that form.

Top-level code in the script (outside any function) runs once when the form loads — use this for initialization: subscribing to parameters, setting initial control states, etc.

Lua editor


Writes a message to the script console in the editor. Accepts any value — tables are printed in a readable form.

sys_log("tab index changed")
sys_log(value)
sys_log({x = 10, y = 20})

A read-only table built into every form script that holds all macro values in effect for the current form instance. This includes the built-in stylesheet macros ($text-color, $knob-size, etc.) and any custom values passed via macroParameters when the form is embedded as a subForm.

Keys are the full macro names including the $ prefix.

-- Read a custom macro passed by the parent form
local paramId = sys_form_macros["$param-id"]
-- Read a built-in stylesheet macro
local textColor = sys_form_macros["$text-color"]

The main use case is when a reusable sub-form needs to subscribe to whichever parameter the parent passed in — something that cannot be expressed in YAML alone:

-- Sub-form script: subscribes to the parameter macro the parent supplied
local uid = sys_form_macros["$param-id"]
sys_subscribe_to_parameter(uid, function(value)
sys_modify_control("indicator", "props", {value = value})
end)

sys_modify_control(control_name, domain, changes)

Section titled “sys_modify_control(control_name, domain, changes)”

Changes one or more properties of a control at runtime.

ArgumentTypeDescription
control_namestringThe control’s ID (the key in the controls: map)
domainstringTop-level field to target: "pos", "anchors", "props", or "" for a root-level field such as visible
changestableKey/value pairs of properties to update

Not all properties can be changed at runtime. Bitmaps, for example, require a preload step at form initialization and cannot be swapped via script.

Examples:

-- Show or hide a control
sys_modify_control("detail_panel", "", {visible = false})
sys_modify_control("detail_panel", "", {visible = true})
-- Move a control (absolute placement)
sys_modify_control("my_knob", "pos", {x = 20, y = 100})
-- Change multiple props at once
sys_modify_control("my_label", "props", {text = "Active", color = "#C0C7FF"})

sys_set_parameter_value(parameter_id, value)

Section titled “sys_set_parameter_value(parameter_id, value)”

Sets a parameter value. parameter_id accepts either the parameter’s UUID or, if it has one, its human-readable name. Engine parameters never have a name — use the UUID copied from the editor. User-defined global parameters have both; the name is usually more readable.

sys_set_parameter_value("08afbf03-738b-48ad-a340-0c482f505683", 0.75)
sys_set_parameter_value("tab-index", 1) -- global parameter, by name

sys_subscribe_to_parameter(parameter_id, callback)

Section titled “sys_subscribe_to_parameter(parameter_id, callback)”

Registers a callback that fires whenever the parameter changes, and returns the parameter’s current value at the time of subscription. parameter_id accepts either a UUID or, if the parameter has one, its name — same rule as sys_set_parameter_value above.

Works particularly well with anonymous functions:

sys_subscribe_to_parameter("uuid-tab-index", function(value)
sys_modify_control("panel_osc", "", {visible = value == 0})
sys_modify_control("panel_filter", "", {visible = value == 1})
sys_modify_control("panel_env", "", {visible = value == 2})
end)

Because the call returns the current value, “read once, then react to changes” is a single line — feed the return value straight back into the same handler function:

local function sync_visibility(value)
sys_modify_control("panel_osc", "", {visible = value == 0})
sys_modify_control("panel_filter", "", {visible = value == 1})
sys_modify_control("panel_env", "", {visible = value == 2})
end
sync_visibility(sys_subscribe_to_parameter("uuid-tab-index", sync_visibility))

Only one subscription per parameter per form is active at a time. Calling sys_subscribe_to_parameter again on the same parameter replaces the previous callback.


sys_send_script_message(channel_name, data)

Section titled “sys_send_script_message(channel_name, data)”

Sends a value to a named channel. Every script that has subscribed to that channel — whether a UI script or a realtime (RT) script — receives the value via its registered callback.

data can be any Lua value: number, bool, string, table, or nil.

Delivery is asynchronous and buffered when crossing the UI/RT thread boundary (UI → RT or RT → UI). Delivery is synchronous between two UI scripts on the same thread.

sys_send_script_message("sequencer", {command = "play"})
sys_send_script_message("sequencer", {command = "stop"})
sys_send_script_message("engine-reset", nil)

sys_subscribe_to_script_messages(channel_name, callback)

Section titled “sys_subscribe_to_script_messages(channel_name, callback)”

Registers a callback that fires whenever a message arrives on the named channel. The callback receives the sent value as its only argument.

sys_subscribe_to_script_messages("sequencer", function(msg)
sys_log("received: " .. msg.command)
end)

Multiple scripts can subscribe to the same channel independently. Each receives every message sent to that channel.


Installs a recurring timer that calls callback every milliseconds milliseconds. The callback receives no arguments. Returns an opaque handle that can be passed to sys_cancel_timer to stop the timer.

local handle = sys_create_timer(100, function()
update_display()
end)

Timers continue firing for the lifetime of the form unless cancelled. Cancel any timer you no longer need to avoid unnecessary CPU wake-ups.


Cancels a recurring timer installed with sys_create_timer. Passing an already-cancelled handle is a no-op.

sys_cancel_timer(handle)

sys_make_color_rgba(red, green, blue, alpha)

Section titled “sys_make_color_rgba(red, green, blue, alpha)”

Creates an HTML color string from RGBA components. All four values are integers in [0 .. 255]. alpha is optional and defaults to 255 (fully opaque). The returned string can be used wherever a color prop is expected.

local red = sys_make_color_rgba(255, 0, 0) -- fully opaque red
local faded = sys_make_color_rgba(0, 128, 255, 128) -- 50% transparent blue
sys_modify_control("my_label", "props", {color = sys_make_color_rgba(200, 100, 50)})

sys_make_color_hsl(hue, saturation, lightness, alpha)

Section titled “sys_make_color_hsl(hue, saturation, lightness, alpha)”

Creates an HTML color string from HSL components. hue is in [0 .. 360], saturation and lightness are in [0.0 .. 1.0], and alpha is an integer in [0 .. 255]. alpha is optional and defaults to 255 (fully opaque).

local warm = sys_make_color_hsl(30, 0.8, 0.5) -- warm orange, opaque
local cool = sys_make_color_hsl(200, 0.6, 0.4, 180) -- semi-transparent blue

sys_subscribe_to_level_collector(processorUid, type, channelOfInterest, granularity, callback)

Section titled “sys_subscribe_to_level_collector(processorUid, type, channelOfInterest, granularity, callback)”

Subscribes to a level collector — an engine resource that reports signal levels at a point in the processing chain identified by processorUid. The processorUid can be copied from the layer editor.

ArgumentTypeDescription
processorUidstringUID of the processor after which levels are measured
typestring"peak", "rms", or "spectrum"
channelOfInterestinteger or nilChannel index to capture, or nil for a mono mix of all channels
granularityinteger or nilFor "spectrum": number of frequency bands. Pass nil for "peak" and "rms".
callbackfunctionCalled each time a new level arrives. Receives a single number ("peak"/"rms") or a table of numbers ("spectrum").
-- Track peak level and drive a meter control
sys_subscribe_to_level_collector("proc-uid", "peak", nil, nil, function(level)
sys_modify_control("meter", "props", {value = level})
end)
-- Spectrum analyser: 32 bands, mono mix of all channels
sys_subscribe_to_level_collector("proc-uid", "spectrum", nil, 32, function(bands)
for i, level in ipairs(bands) do
sys_modify_control("bar_" .. i, "props", {value = level})
end
end)

The canvas control lets you render arbitrary graphics via Lua and optionally handle mouse events. Use it whenever built-in controls cannot express what you need.

my_canvas:
type: canvas
pos: { x: 0, y: 0, w: 300, h: 200 }
props:
onPaint: on_paint # required
onMouseDown: on_mouse_down # optional
onMouseUp: on_mouse_up # optional
onMouseMove: on_mouse_move # optional
onMouseDoubleClick: on_dbl # optional
function on_paint(controlName, ctx, width, height)
-- ctx must be passed to every sys_canvas_* call
end
function on_mouse_down(controlName, x, y) end
function on_mouse_up(controlName) end
function on_mouse_move(controlName, x, y) end
function on_dbl(controlName, x, y) end

Many drawing functions accept a paint table that describes how to draw:

FieldTypeDescription
colorstringHTML color string — use sys_make_color_rgba, sys_make_color_hsl, or a hex literal
strokeWidthnumber or nil> 0: stroke mode (outlines at that width). Absent or ≤ 0: fill mode (solid fill of the shape).
blendModestring or nilOptional. How the shape is composited over the existing background. Absent = "srcOver". See Blend modes for behavior descriptions.

Valid blendMode values: clear src dst srcOver dstOver srcIn dstIn srcOut dstOut srcATop dstATop xor plus modulate screen overlay darken lighten colorDodge colorBurn hardLight softLight difference exclusion multiply hue saturation color luminosity

local fill = {color = "#FF6040"}
local stroke = {color = "#FFFFFF", strokeWidth = 2}
local blended = {color = "#f2c", blendMode = "screen"}

sys_canvas_draw_line(ctx, paint, xFrom, yFrom, xTo, yTo)

Section titled “sys_canvas_draw_line(ctx, paint, xFrom, yFrom, xTo, yTo)”

Draws a straight line. paint.strokeWidth controls the line thickness.


sys_canvas_draw_rect(ctx, paint, x, y, width, height, cornerRadius)

Section titled “sys_canvas_draw_rect(ctx, paint, x, y, width, height, cornerRadius)”

Draws a rectangle. cornerRadius is optional; when present and > 0 it draws a rounded rectangle.


sys_canvas_draw_oval(ctx, paint, x, y, width, height)

Section titled “sys_canvas_draw_oval(ctx, paint, x, y, width, height)”

Draws an ellipse inside the given bounding box.


sys_canvas_draw_arc(ctx, paint, x, y, width, height, startAngle, sweepAngle, useCenter)

Section titled “sys_canvas_draw_arc(ctx, paint, x, y, width, height, startAngle, sweepAngle, useCenter)”

Draws an arc inside the given bounding box. startAngle and sweepAngle are in degrees (0° = 3 o’clock, clockwise). When useCenter is true, lines connect the arc endpoints to the center (pie slice); when false, only the arc is drawn.


Paths compose arbitrary shapes from line and curve segments; the whole path is drawn in a single call. Each path is identified by a name you choose; names are local to the current onPaint invocation.

sys_canvas_path_create(ctx, pathName) — creates a new named path.

sys_canvas_path_move_to(ctx, pathName, x, y) — moves the pen to (x, y) without adding a segment.

sys_canvas_path_line_to(ctx, pathName, x, y) — adds a straight segment from the current pen position to (x, y).

sys_canvas_path_bezier_to(ctx, pathName, x, y, ctrlX1, ctrlY1, ctrlX2, ctrlY2) — adds a Bézier curve to (x, y). With all four control values (ctrlX1, ctrlY1, ctrlX2, ctrlY2) the result is a cubic Bézier; with only ctrlX1, ctrlY1 it is quadratic. ctrlX2/ctrlY2 are the optional pair.

sys_canvas_path_close(ctx, pathName) — closes the path back to its starting point.

sys_canvas_draw_path(ctx, paint, pathName) — renders the named path.

sys_canvas_path_create(ctx, "shape")
sys_canvas_path_move_to(ctx, "shape", 0, height / 2)
sys_canvas_path_line_to(ctx, "shape", width / 2, 0)
sys_canvas_path_line_to(ctx, "shape", width, height / 2)
sys_canvas_path_close(ctx, "shape")
sys_canvas_draw_path(ctx, {color = "#40C0FF"}, "shape")

Transforms affect all drawing calls that follow them. Use save/restore to limit a transform’s scope to a specific part of the paint callback.

sys_canvas_save_state(ctx) — pushes the current transform (and context state) onto a stack.

sys_canvas_restore_state(ctx) — pops the most recently saved state, undoing all transforms since the matching sys_canvas_save_state.

sys_canvas_rotate(ctx, angle) — rotates the drawing context by angle degrees around the control center.

sys_canvas_transform(ctx, matrix) — applies an arbitrary 4×4 transformation matrix. matrix must be a Lua index table of 16 numbers in column-major order.

-- Rotate a rectangle 45° around the control centre (inside paintMe)
sys_canvas_save_state(ctx)
sys_canvas_rotate(ctx, 45)
sys_canvas_draw_rect(ctx, {color = "#FF8000"},
width / 2 - 20, height / 2 - 20, 40, 40)
sys_canvas_restore_state(ctx)

Schedules a repaint of the named canvas control. The canvas does not redraw automatically — you must call this function whenever the visual state changes, for example from a level-collector callback or a timer. Calling it multiple times before the next frame is safe; repaints are coalesced.

sys_canvas_repaint("my_canvas")

A self-contained canvas that subscribes to an 8-band spectrum collector, holds peaks and decays them over time, and renders each band as a glowing circle arranged in a zigzag row.

-- Three concentric stroke-only ovals at increasing radii produce a soft
-- glow halo around each circle. Brighter color = narrower outer ring.
local fatPaint = {color = "#622c", strokeWidth = 40.0}
local midPaint = {color = "#822c", strokeWidth = 20.0}
local thinPaint = {color = "#b22c", strokeWidth = 10.0}
local numBands = 8
-- Per-band energy; held at the incoming peak value and decayed each timer tick.
local levels = {}
for i = 1, numBands do levels[i] = 0.0 end
-- Hold-and-decay model: only update a band when the incoming value exceeds the
-- current one. Trigger a repaint only when at least one band changed.
sys_subscribe_to_level_collector(
"186e138b-0420-4f1d-8540-f83191138776", "spectrum", nil, numBands,
function(val)
local repaint = false
for i = 1, numBands do
if levels[i] < val[i] then
levels[i] = val[i]
repaint = true
end
end
if repaint then
sys_canvas_repaint("new.canvas")
end
end
)
-- Timer fires every 12 ms (~83 fps). Applies a 3 % per-frame decay to each
-- active band and requests a repaint if the level is still meaningful.
sys_create_timer(12, function()
for i = 1, numBands do
if levels[i] > 0.0001 then
levels[i] = levels[i] * 0.97
sys_canvas_repaint("new.canvas")
end
end
end)
-- Draws three concentric ovals at slightly increasing radii, creating the
-- layered glow effect around each visualiser dot.
local function drawCircle(ctx, centerX, centerY, radius)
local r1, r2, r3 = radius, radius * 1.04, radius * 1.09
sys_canvas_draw_oval(ctx, fatPaint, centerX - r1, centerY - r1, r1 * 2, r1 * 2)
sys_canvas_draw_oval(ctx, midPaint, centerX - r2, centerY - r2, r2 * 2, r2 * 2)
sys_canvas_draw_oval(ctx, thinPaint, centerX - r3, centerY - r3, r3 * 2, r3 * 2)
end
-- Paint callback. Bands are spaced evenly across the canvas width, alternating
-- above and below the vertical centre (zigzag). Circle size scales with energy.
function paintMe(controlName, ctx, width, height)
local maxRadius = math.min(width, height) / 5
local hopWidth = (width - 2 * maxRadius) / numBands
local x = maxRadius
local yOffset = height / 9
for i = 1, numBands do
local y = height / 2 + yOffset
drawCircle(ctx, x, y, maxRadius * levels[i] * 0.25)
yOffset = -yOffset -- alternate above / below centre
x = x + hopWidth
end
end

YAML to wire the canvas:

new.canvas:
type: canvas
anchors: { left: 0, right: 0, top: 0, bottom: 0 }
props:
onPaint: paintMe

Top-level script code runs once when the form loads. Use it to set up subscriptions and initialize any state:

local function sync_mode(value)
sys_modify_control("wavetable_panel", "", {visible = value})
sys_modify_control("classic_panel", "", {visible = not value})
end
-- Subscribe to a parameter that drives visibility. sys_subscribe_to_parameter
-- returns the current value, so passing it straight into the handler applies
-- the initial state in the same line — no separate sync step needed.
sync_mode(sys_subscribe_to_parameter("uuid-engine-mode", sync_mode))
sys_log("script loaded")

A canonical pattern: a toggle switches which sub-panel is visible. This cannot be achieved purely with declarative bindings, making it a natural fit for scripting.

YAML:

mode_toggle:
type: toggle
pos: { x: 8, y: 8, w: 48, h: 24 }
props:
parameterUid: "uuid-engine-mode"

Script:

local function sync_mode(value)
sys_modify_control("wavetable_panel", "", {visible = value})
sys_modify_control("classic_panel", "", {visible = not value})
end
sync_mode(sys_subscribe_to_parameter("uuid-engine-mode", sync_mode))

Because both the toggle and the subscription use the same parameter, the visibility stays in sync even if the parameter is changed from elsewhere (e.g. a preset load). Feeding the subscription’s return value into the same handler also applies the correct initial state immediately.

button has no parameterUid — it is always handler-driven:

randomize_btn:
type: button
pos: { x: 200, y: 10, w: 80, h: 28 }
props:
text: Randomize
onPressed: on_randomize
function on_randomize(controlName)
sys_set_parameter_value("uuid-osc-pitch", math.random())
sys_set_parameter_value("uuid-filter-cut", math.random())
sys_log("randomized!")
end

The primary use case for script messaging is triggering behaviour in a realtime (RT) script from UI controls. The UI script sends a message; the RT script, registered on the same channel, acts on it. The channel name is an arbitrary string agreed on by both sides.

YAML — two buttons, each wired to a handler:

play_btn:
type: button
pos: { x: 8, y: 8, w: 60, h: 28 }
props:
text: Play
onPressed: on_play
stop_btn:
type: button
pos: { x: 76, y: 8, w: 60, h: 28 }
props:
text: Stop
onPressed: on_stop

UI script — sends messages to the RT script:

function on_play(controlName)
sys_send_script_message("sequencer", {command = "play"})
end
function on_stop(controlName)
sys_send_script_message("sequencer", {command = "stop"})
end

RT script (elsewhere in the instrument) — listens on the same channel:

sys_subscribe_to_script_messages("sequencer", function(msg)
if msg.command == "play" then
start_sequencer()
elseif msg.command == "stop" then
stop_sequencer()
end
end)

Because UI → RT delivery is buffered and asynchronous, the UI script returns immediately and never blocks the audio thread.


Note: The scripting surface is an initial set of tools designed to cover the cases where declarative bindings fall short. It will expand meaningfully over time.