Lua Scripting Reference¶
MAGDA hosts user Lua 5.4 scripts to drive MIDI controllers and automate the DAW. Every script runs in a sandboxed runtime — no io, os, package, or require; just Lua's standard string, math, table, and coroutine libraries plus the magda.* API.
API stability
The magda.* surface is unstable in 0.7. Method names, argument shapes, and which fields are exposed on snapshot tables are likely to change in 0.8 as more controllers are scripted against it. Pin a script to a release if you want it to keep working.
For an end-to-end overview of how to load scripts and the footer indicator, see Controllers — Lua Controller Scripts.
Script Lifecycle¶
A script is a plain .lua file. Top-level code runs once when the script is loaded; after that, MAGDA dispatches events to three optional callback functions:
| Callback | When it fires | Arguments |
|---|---|---|
on_load() |
Once after the script is loaded and the magda table is installed |
none |
on_midi(event) |
For every incoming MIDI event matching the script's assigned input port (or every event on every input, if the port is left blank) | the event table — see below |
on_tick(dt) |
~30 Hz | dt — seconds since the last tick (number) |
All three are called on the message thread. MIDI input arrives on the audio thread but is hopped to the message thread before on_midi fires, so the entire magda.* API is safe to call from anywhere.
function on_load()
magda.log.info("script loaded, MAGDA " .. magda.app.version())
end
function on_midi(event)
-- ...
end
function on_tick(dt)
-- e.g. blink an LED every half second
end
MIDI Event Shape¶
The event table passed to on_midi has these fields:
| Field | Type | Description |
|---|---|---|
type |
string | "cc", "note_on", "note_off", "pitch_bend", "aftertouch", "program_change", "sysex", or "other" |
channel |
integer | MIDI channel 1..16 (0 for sysex — no channel) |
number |
integer | CC number, note number, program number, or 0 (pitch bend, channel-pressure aftertouch, sysex) |
value |
integer | CC value 0..127, velocity 0..127, pitch-bend −8192..8191, aftertouch pressure 0..127 |
bytes |
array of integers | Sysex only — the payload bytes, 1-indexed, not including F0/F7 framing |
port |
string | Display name of the originating MIDI input |
API Reference¶
Every function below lives under the global magda table. All are message-thread safe.
magda.log¶
| Function | Description |
|---|---|
magda.log.info(msg) |
Log at info level (visible in MAGDA's debug console) |
magda.log.warn(msg) |
Log at warn level |
magda.log.error(msg) |
Log at error level |
magda.app¶
| Function | Returns | Description |
|---|---|---|
magda.app.version() |
string | MAGDA version (e.g. "0.7.0") |
magda.transport¶
| Function | Returns | Description |
|---|---|---|
magda.transport.play() |
— | Start playback |
magda.transport.stop() |
— | Stop playback |
magda.transport.set_recording(enabled) |
— | Set the record-arm state of the transport |
magda.transport.is_playing() |
boolean | |
magda.transport.is_recording() |
boolean | |
magda.transport.is_loop_enabled() |
boolean | |
magda.transport.set_loop_enabled(enabled) |
— | |
magda.transport.position_beats() |
number | Current playhead position, in beats |
magda.transport.set_position_beats(beats) |
— | Move the playhead |
magda.transport.seek_beats(delta) |
— | Move the playhead by delta beats. Stops at the start of the project |
magda.transport.seek_bars(delta) |
— | Move the playhead by delta bars, using the project's meter. Stops at the start of the project |
Rewind and fast-forward buttons¶
seek_beats and seek_bars are the relative pair. They exist because a
transport button is relative by nature: it says "back one bar", not "go to bar
seven". Written with set_position_beats instead, every script re-derives the
same two things — the clamp at the start of the project, and how long a bar
is, which stops being four beats the moment the project is not in four.
function on_midi(event)
-- MCU rewind and fast forward, as an Arturia KeyLab sends them.
if event.type ~= "note_on" then return end
if event.number == 0x5B then
magda.transport.seek_bars(-1)
elseif event.number == 0x5C then
magda.transport.seek_bars(1)
end
end
Holding the button to scrub. These buttons are usually held rather than tapped. MAGDA has no auto-repeat of its own, so the script drives it: remember which direction is held, and move on each tick.
local scrub = 0
local BEATS_PER_SECOND = 8
function on_midi(event)
if event.number ~= 0x5B and event.number ~= 0x5C then return end
if event.type == "note_off" then
scrub = 0
elseif event.type == "note_on" then
scrub = (event.number == 0x5B) and -1 or 1
end
end
function on_tick(dt)
if scrub ~= 0 then
magda.transport.seek_beats(scrub * BEATS_PER_SECOND * dt)
end
end
Scale by dt rather than moving a fixed amount per tick: ticks arrive at about
30 Hz, so a fixed beat per tick is thirty beats a second and the shuttle rate
would change if that rate ever did. BEATS_PER_SECOND is then the number to
tune, and it means what it says.
A surface that repeats note-on while a button is held needs no on_tick at
all — seek on each repeat and drop the scrub variable. Every repeat moves the
playhead, because a relative seek is an edge rather than a value and nothing
coalesces it away.
magda.project¶
| Function | Returns | Description |
|---|---|---|
magda.project.info() |
table | { name, file_path, tempo, time_sig_num, time_sig_den, sample_rate, loop_enabled } |
magda.selection¶
| Function | Returns | Description |
|---|---|---|
magda.selection.track() |
integer or nil | Selected track id |
magda.selection.clip() |
integer or nil | Selected clip id |
magda.selection.clips() |
array of integers | All selected clip ids (order unspecified) |
magda.selection.has_notes() |
boolean | A note selection is active inside a MIDI editor |
magda.selection.note_clip() |
integer or nil | Clip id the note selection lives in |
magda.selection.note_indices() |
array of integers | Selected note indices within that clip |
magda.selection.select_track(id) |
— | |
magda.selection.select_tracks({id, id, ...}) |
— | Multi-select |
magda.selection.select_clip(id) |
— | |
magda.selection.select_clips({id, id, ...}) |
— | Multi-select |
magda.selection.clear_notes() |
— | Clear note selection |
magda.tracks¶
| Function | Returns | Description |
|---|---|---|
magda.tracks.create(name [, type]) |
integer | Track id; type is "audio" (default), "group", "aux", "master", or "multi_out" |
magda.tracks.delete(id) |
— | |
magda.tracks.count() |
integer | |
magda.tracks.list() |
array of tables | Each table: { id, name, type, volume, pan, muted, soloed, record_armed, frozen }. Does not include the master track |
magda.tracks.get(id) |
table or nil | Same shape as the list entries |
magda.tracks.master() |
table or nil | The master track, which never appears in list() |
magda.tracks.set_name(id, name) |
— | |
magda.tracks.set_volume(id, value) |
— | Linear gain 0..1 |
magda.tracks.set_pan(id, value) |
— | -1..1 |
magda.tracks.set_muted(id, bool) |
— | |
magda.tracks.set_soloed(id, bool) |
— | |
magda.tracks.set_record_armed(id, bool) |
— | Ignored on tracks that take no external input (Aux, Group) |
Reaching the master track¶
The master is stored apart from the ordinary tracks, so it is deliberately absent
from magda.tracks.list() and list positions stay aligned with what a control
surface shows. Use magda.tracks.master() to get its id, then pass that id to
the ordinary setters:
local master = magda.tracks.master()
if master then
magda.tracks.set_volume(master.id, e.value / 127.0)
end
magda.clips¶
| Function | Returns | Description |
|---|---|---|
magda.clips.create_midi(track_id, start_beats, length_beats) |
integer | New MIDI clip id |
magda.clips.delete(id) |
— | |
magda.clips.list_on_track(track_id) |
array of integers | Clip ids on that track |
magda.clips.list_arrangement() |
array of tables | Each table: { id, track_id, name, start_beats, length_beats } |
magda.clips.set_name(id, name) |
— | |
magda.clips.set_groove(id, template_name) |
— | Apply a groove template by name |
magda.clips.colour(id) |
table or nil | { r, g, b } with each component 0..127 (7-bit, sysex-ready) |
magda.session¶
| Function | Returns | Description |
|---|---|---|
magda.session.launch_clip(clip_id) |
— | Launch a session clip |
magda.session.stop_clip(clip_id) |
— | |
magda.session.stop_track(track_id) |
— | Stop the currently-active session clip on a track |
magda.session.stop_all() |
— | |
magda.session.launch_scene(scene_index) |
— | 0-based; tracks with empty slots in that row have their active clip stopped (matches the UI scene-button click) |
magda.session.active_clip_on_track(track_id) |
integer or nil | Currently-playing session clip on the track |
magda.session.clip_in_slot(track_id, scene_index) |
integer or nil | Clip in a specific slot, or nil if empty |
magda.session.clip_play_state(clip_id) |
string | "stopped", "queued", or "playing" |
magda.session.set_view(scene_offset [, scene_count]) |
— | Publish the controller's visible scene window so MAGDA's UI can highlight it |
magda.focused¶
The "focused" device is the device currently shown in the parameter view. Profiles with focused.macro.* resolvers and the AI panel both read it.
| Function | Returns | Description |
|---|---|---|
magda.focused.has_focus() |
boolean | A device is focused |
magda.focused.name() |
string | Display name of the focused device |
magda.focused.macro_name(index) |
string | Name of macro index (0..7) |
magda.focused.macro_value(index) |
number | Current value 0..1 |
magda.focused.set_macro(index, value) |
— | Write a macro value 0..1 |
magda.focused.cycle_device(direction) |
— | Move focus through the chain — +1 next, -1 previous |
magda.focused.auto_map() |
— | Engage automap so the controller's profile drives the focused device's macros |
magda.focused.clear_auto_map() |
— | Disengage automap |
magda.midi¶
For driving feedback (motorised faders, LED rings, screens) back to the controller. The port argument is the display name of a MIDI output — pass "@default" (or the empty string) to use the script's assigned output port.
| Function | Returns | Description |
|---|---|---|
magda.midi.send(port, status, data1 [, data2]) |
boolean | Raw channel-voice message; returns true if the port was found |
magda.midi.send_cc(port, channel, number, value) |
boolean | |
magda.midi.send_note_on(port, channel, note, velocity) |
boolean | |
magda.midi.send_note_off(port, channel, note) |
boolean | |
magda.midi.send_sysex(port, {byte, byte, ...}) |
boolean | F0/F7 framing is added by the binding — pass payload bytes only |
magda.midi.outputs() |
array of strings | Names of every connected MIDI output port |
magda.midi.default_output() |
string | The script's currently-assigned output port |
Examples¶
Sustain pedal launches the selected session clip¶
function on_midi(e)
if e.type ~= "cc" or e.number ~= 64 then return end
local track = magda.selection.track()
if not track then return end
if e.value >= 64 then
local clip = magda.selection.clip()
if clip then magda.session.launch_clip(clip) end
else
magda.session.stop_track(track)
end
end
Eight knobs map to track volumes¶
function on_midi(e)
if e.type ~= "cc" then return end
if e.number < 1 or e.number > 8 then return end
magda.tracks.set_volume(e.number, e.value / 127.0)
end
Light the play button while the transport is rolling¶
local PLAY_NOTE = 60
local was_playing = false
function on_tick(_dt)
local playing = magda.transport.is_playing()
if playing == was_playing then return end
was_playing = playing
if playing then
magda.midi.send_note_on("@default", 1, PLAY_NOTE, 127)
else
magda.midi.send_note_off("@default", 1, PLAY_NOTE)
end
end
Bundled Examples¶
Working scripts ship inside the application:
foot-pedal.lua— sustain pedal launches and stops the selected session clip8-knobs.lua— eight CCs drive eight track volumeslaunchpad_mk1.lua— Novation Launchpad MK1 / Classic: session grid, scene launch, banking, User 1/2 note layoutslaunchpad_mk2.lua— the same surface for the Launchpad MK2launchkey_mini_mk4.lua— full Launchkey Mini MK4 surface (transport, pads, knobs, scene switching)
Add any of them from the Controllers dialog's Scripts tab with + Add script — see Controllers — Factory Scripts. To adapt one, reveal it on disk and copy it into your own scripts folder under a new name.