The Usual Suspects

Digitally Preserving Classic Synthesizers through Emulation

Lua Scripting for Skins

Gearmulator supports Lua scripting in plugin skins, giving skin authors the power to create dynamic, interactive UI elements without writing C++ code. Using Lua, you can read and write synthesizer parameters, react to parameter changes in real time, and manipulate the UI layout and appearance on the fly.

This feature is aimed at skin designers who want to go beyond static layouts and build things like custom visualizations, conditional UI logic, animated controls, or parameter-driven display elements.

Gearmulator uses Lua 5.4 embedded in the RmlUi skin framework. If you’re new to RmlUi, check the official RmlUi documentation for details on RML elements, RCSS properties, data bindings, and events. For an overview of the skinning system itself, see the Skinning Guide.

Drawing custom graphics? To render raster graphics (shapes, meters, scopes) on a <canvas> element from Lua using an HTML5-style 2D drawing API, see the Canvas Scripting Guide.

Quick Start

Add a <script> block in your RML file’s <head> section to define Lua functions, then call them from event handlers on your elements:

<rml>
  <head>
    <link type="text/rcss" href="myskin.rcss"/>
    <script>
      function onBodyLoad()
        local val = params.get("Osc1Pitch")
        Log.Message(Log.logtype.info, "Osc1Pitch = " .. val)
      end
    </script>
  </head>
  <body onload="onBodyLoad()">
    ...
  </body>
</rml>

Script Execution Timing

Understanding when scripts run is important for writing correct Lua code:

Global Variables

These globals are available in all Lua scripts and event handlers:

Global Type Description
document ElementDocument The current RML document. Use for DOM access like document:GetElementById().
params table Parameter API for reading/writing synth parameters and subscribing to changes.
midi table Live note on/off events.
Log table RmlUi logging. Use Log.Message(Log.logtype.info, "message").
rmlui table RmlUi core API (contexts, font loading, etc.).
skinvars table Storage for the skin’s own state, per instance or shared by all of them.

Parameter API

The params table provides full access to the synthesizer’s parameters. The part argument is optional for all functions — when omitted, the currently selected part is used.

Reading Values

-- Get raw integer value (current part)
local value = params.get("Osc1Pitch")

-- Get for a specific part
local value = params.get("Osc1Pitch", 3)

-- Get for explicit "current" part
local value = params.get("Osc1Pitch", "current")

-- Get formatted display text
local text = params.getText("Osc1Pitch")
local text = params.getText("Osc1Pitch", 3)

Writing Values

-- Set value on current part
params.set("Osc1Pitch", 64)

-- Set value on specific part
params.set("Osc1Pitch", 64, 3)

Parameter Info

local info = params.getInfo("Osc1Pitch")
-- info.min          (integer) minimum value
-- info.max          (integer) maximum value
-- info.name         (string)  internal parameter name
-- info.displayName  (string)  human-readable name
-- info.defaultValue (integer) default value
-- info.isBool       (boolean) true if parameter is a toggle
-- info.isBipolar    (boolean) true if parameter is bipolar

Subscribing to Changes

-- Subscribe to changes on current part (auto-rebinds when part changes)
local id = params.onChange("Osc1Pitch", function(value, text)
  -- value: raw integer
  -- text: formatted display string
end)

-- Subscribe to changes on a specific part
local id = params.onChange("Osc1Pitch", function(value, text)
  -- ...
end, 3)

-- Unsubscribe
params.removeListener(id)

When subscribing with the current part (the default), the listener automatically rebinds to the new part’s parameter when the current part changes. If the value differs between the old and new part, the callback fires immediately with the new value.

Current Part

-- Get current part number (0-based)
local part = params.getCurrentPart()

-- Subscribe to part changes
local id = params.onPartChanged(function(newPart)
  -- newPart: 0-based part number
end)

Frame Events

Anything that has to keep moving by itself — an oscilloscope, a VU meter, a scrolling marquee — needs a clock, because parameter callbacks only fire when a value changes. A document receives a frame event once per rendered frame if, and only if, it declares an onframe handler on its <body>:

<body onframe="onFrame(event)">
function onFrame(event)
  local time  = event.parameters['time']   -- seconds since the editor was opened
  local delta = event.parameters['delta']  -- seconds since the previous frame event

  local needle = document:GetElementById("needle")
  needle.style.left = tostring(200 + 180 * math.sin(time * 2)) .. "dp"
end

Drive movement from delta or time rather than counting frames — the rate is not guaranteed and differs per machine.

To use AddEventListener instead of an inline handler, declare an empty attribute — <body onframe=""> — because the attribute is what opts the document in:

document:AddEventListener("frame", function(event)
  -- ...
end)

A few things worth knowing:

MIDI Notes

Notes played into the plugin never reach the parameter API — they go straight to the emulated hardware — so a skin cannot draw a keyboard from params alone. The midi table reports them as they arrive:

midi.onNoteOn(function(note, velocity, channel)
  -- note 0-127, velocity 1-127, channel 1-16
  local key = document:GetElementById("key" .. note)
  if key then key:SetClass("pressed", true) end
end)

midi.onNoteOff(function(note, channel)
  local key = document:GetElementById("key" .. note)
  if key then key:SetClass("pressed", false) end
end)

Both return an id for midi.removeListener(id).

Notes are reported whatever their source — the host, a connected controller, or the plugin’s own editor — and before MIDI Learn or program change routing can consume them, so a key mapped to something else still lights up.

A note on with velocity 0 arrives as a note off, which is how a lot of gear and many DAWs release a note. Handling it any other way leaves keys stuck down, so onNoteOff fires for it and onNoteOn does not.

Callbacks run on the UI thread even though the notes arrive on the audio thread, so a callback can touch the DOM directly. A skin that registers no note callbacks costs nothing per event.

Only note on and note off are reported. Control changes, pitch bend and aftertouch are not, because a mod wheel or an automation lane produces them by the thousand and a skin cannot keep up with that on the UI thread.

Skin Variables

State that belongs to the skin rather than to the synth — which knobs a skin has linked together, which of its own pages was open — has nowhere to live in the parameter set, and a plain Lua variable is gone as soon as the editor closes. The skinvars table is a small store for exactly that, in two scopes:

Scope Stored in Survives
"instance" the plugin state saving and loading the host project, per instance
"global" the plugin config file every instance and every project, until changed
skinvars.set("oscLink", 1)                  -- instance scope, the default
skinvars.set("theme", "dark", "global")     -- shared by every instance

local link  = skinvars.get("oscLink")       -- instance first, then global, nil if neither
local theme = skinvars.get("theme", "global")

Values are numbers or strings, and the type survives storage, so "7" and 7 stay apart. Booleans are accepted and stored as 1 and 0.

Reading without a scope answers from the instance first and falls back to the global. That is what makes a skin-wide default work: ship the default in the global scope and let a project override it for one instance without disturbing the others.

Reacting to Changes

local id = skinvars.onChange("oscLink", function(value, scope)
  -- scope is "instance" or "global"
end)

skinvars.removeListener(id)

A third argument limits the callback to one scope:

skinvars.onChange("theme", onThemeChanged, "global")

A callback registered without a scope fires for both, and the value it receives is what a plain get() would answer — so if an instance value shadows the global one, a change to the global still reports the instance value. Name the scope explicitly when that matters.

Loading a project reports every instance-scope variable it carries as a change, so a skin that rebuilds its UI in the callback picks up a restored project on its own.

Removing

skinvars.remove("oscLink")            -- instance scope
skinvars.remove("theme", "global")

Global variables are written to the plugin’s config XML as soon as they change, one key per variable, prefixed skinvar_.

DOM Manipulation

The document global provides access to the RmlUi DOM. You can find elements, modify styles, toggle classes, and build dynamic content:

-- Find elements
local elem = document:GetElementById("myElement")

-- Read/write styles
elem.style.width = "100px"
elem.style.height = "50px"
elem.style["background-color"] = "#ff0000"

-- Read computed size (in px, available after layout)
local w = elem.offset_width
local h = elem.offset_height

-- Add/remove CSS classes
elem:SetClass("active", true)
elem:SetClass("hidden", false)

-- Read/write attributes
local val = elem:GetAttribute("id")
elem:SetAttribute("data-value", "42")

-- Read/write inner RML
elem.inner_rml = '<div class="child">Hello</div>'

-- Navigate the tree
local parent = elem.parent_node
local first = elem.first_child
local doc = elem.owner_document

Logging

Log.Message(Log.logtype.info, "informational message")
Log.Message(Log.logtype.warning, "warning message")
Log.Message(Log.logtype.error, "error message")

Log levels: always, error, warning, info, debug.

Example: Arpeggiator User Pattern Visualization

This example creates a bar graph showing the arpeggiator step pattern for the Virus TI, with bar width controlled by step length and height by step velocity. It demonstrates the key patterns for building parameter-driven visualizations.

Styles and script in <head>:

<style>
  .arp-bar {
    position: absolute;
    bottom: 0px;
    background-color: #ffffffdd;
  }
  .arp-bar-even { background-color: #ccddffcc; }
  .arp-bar-odd  { background-color: #ddccffcc; }
  .arp-bar-inactive { background-color: #aaaaaa55; }
</style>
<script>
  local STEPS = 32
  local bars = {}
  local container = nil

  function updateArpBar(step)
    local bar = bars[step]
    if bar == nil or container == nil then return end

    local cw = container.offset_width
    local ch = container.offset_height
    if cw <= 0 or ch <= 0 then return end
    local stepMaxW = cw / STEPS

    local length   = params.get("Step " .. step .. " Length")
    local velocity = params.get("Step " .. step .. " Velocity")
    local bitfield = params.get("Step " .. step .. " Bitfield")
    local patLen   = params.get("Arpeggiator/UserPatternLength")

    local active = (step - 1) <= patLen and bitfield > 0

    bar.style.left   = math.floor((step - 1) * stepMaxW) .. "px"
    bar.style.width  = math.floor((length / 127) * stepMaxW) .. "px"
    bar.style.height = math.floor((velocity / 127) * ch) .. "px"

    bar:SetClass("arp-bar-inactive", not active)
  end

  function updateAllArpBars()
    for i = 1, STEPS do updateArpBar(i) end
  end

  function initArp()
    container = document:GetElementById("LuaArpUserGraphics")
    for i = 1, STEPS do
      bars[i] = document:GetElementById("arp-bar-" .. i)
    end
    for i = 1, STEPS do
      local step = i
      params.onChange("Step " .. step .. " Length",    function() updateArpBar(step) end)
      params.onChange("Step " .. step .. " Velocity",  function() updateArpBar(step) end)
      params.onChange("Step " .. step .. " Bitfield",  function() updateArpBar(step) end)
    end
    params.onChange("Arpeggiator/UserPatternLength", function() updateAllArpBars() end)
  end
</script>

Container and bars in <body>:

<body onload="initArp()">
  ...
  <div id="LuaArpUserGraphics" onshow="updateAllArpBars()"
       style="width: 1921dp; height: 476dp; position: relative; overflow: hidden;">
    <div id="arp-bar-1"  class="arp-bar arp-bar-odd"/>
    <div id="arp-bar-2"  class="arp-bar arp-bar-even"/>
    <!-- ... bars 3-31 ... -->
    <div id="arp-bar-32" class="arp-bar arp-bar-even"/>
  </div>
  ...
</body>

Key patterns:

Debugging

RmlUi Debugger

Gearmulator includes the RmlUi visual debugger. To enable it, open the plugin’s settings and enable it in the Skin section. The debugger overlay will appear immediately.

The debugger provides:

Debug Logging from Lua

Use Log.Message to write to the RmlUi event log:

Log.Message(Log.logtype.info, "debug: value = " .. tostring(myVar))

These messages appear in the debugger’s Event Log panel. Lua errors (syntax errors, runtime errors, timeouts) are also logged there automatically with full stack tracebacks.

Safety

Sandboxed Standard Libraries

Only safe Lua standard libraries are loaded: base, coroutine, table, string, math, utf8. The following are deliberately excluded:

Library Reason
io Filesystem access
os Shell commands, file operations
package Loading arbitrary native code via require
debug Internal state inspection/modification

Execution Timeout

Scripts are limited to 3 seconds of execution time. If a script exceeds this limit (e.g., an accidental infinite loop), it is terminated with an error message and the plugin continues working normally. The timeout applies to all Lua execution: inline scripts, external scripts, event handlers, and parameter callbacks.

Error Handling

All Lua errors are caught via protected calls. Errors are logged as warnings through the RmlUi logging system and never crash the plugin or DAW. A full stack traceback is included in the error message.

Adding Lua Files to Skins

Place .lua files in the skin folder alongside .rml, .rcss, and image files. They are automatically compiled into the plugin’s binary data and can be loaded from RML:

<script src="myskin.lua"></script>

This lets you keep your Lua code in separate files for better organization, especially for larger scripts.