uvi-script
Musical event scripting with Lua
Loading...
Searching...
No Matches
Examples Gallery

This page collects all bundled Lua examples, organized by category. Each script is a self-contained starting point that you can load directly into Falcon and tweak to fit your own patches.

Note ProcessingVoice EffectsPerformance & ArticulationSequencingMIDI ToolsAsset Loading

UI Helpers

Chorder
chord harmonizer with presets

Ensemble
ensemble with pan and time spread

legato
legato with crossfade and retrigger

Arpeggiator
classic chord arpeggiator (up / down / updown, octave range)

CCFilter
block a specific midi cc

IRLoader
hierarchical ir menu with userready guard

FX Controls
bind a program insert effect to the script ui

InvertPitch
mirror notes around a center pitch

tremolo
amplitude and pan lfo

MappingArticulations
switching mappings and articulations at run time

DrumSequencer
eight-track drum grid with mixed step resolutions

CCRedirect
remap a midi cc number to another

SampleDropper
load samples via drag and drop

PanelSwitcher
tab-based panel switching with main/fx/seq views

Keyswitch
automatic layer switching via keyswitches

Unison
detuned unison voices

monoBassLine
monophonic bass synth with sequencer

StepSequencer
beat-synced step sequencer with position display

CCSmooth
smooth incoming cc messages over time

TemporaryDisplay
flash knob values on labels with auto-revert

Latch
latch / hold with mid-hold toggle safety

vibrato
per-voice pitch vibrato

portamento
portamento with pitch glide

MidiLearn
midi learn for note assignment

quarterTone
quarter-tone keyboard mapping

VoiceTracker
track and manipulate active voices

TimbreShifting
borrow neighbouring keygroup timbres


Note Processing

Chorder

Chord Harmonizer with Presets

Generates chords by playing up to 6 simultaneous notes with configurable pitch shifts and velocity scaling. Ships with several named presets (Major, Fifth, Jazz, Debusian, etc.) selectable from a Menu widget. Worth studying: the preset Menu pattern. The Shift / Velocity knobs are left .persistent = true (default) so the engine round-trips their values across save / load. The preset Menu itself is .persistent = false so the engine does NOT restore it via the normal path — that would fire its .changed callback during the restore and overwrite the just-restored knob values. We round-trip the menu's visual selection ourselves with setValue(index, false) in onLoad, where the second arg false tells the widget NOT to fire .changed. The user's last-picked preset name is shown again, the knobs keep their tweaks, and no side effect re-fires.

Demonstrates: playNote, Knob, Menu, onSave, onLoad, .persistent, setValue notify flag

local shift = {}
local velocity = {}
local presets = {
{"--", {0, 1}, {0, 1}, {0, 1}, {0, 1}, {0, 1}, {0, 1}},
{"Default", {0, 1}, {0, 1}, {0, 1}, {0, 1}, {0, 1}, {0, 1}},
{"Debusian", {-16, 1.02}, {3, 0.54}, {18, 0.83}, {-6, 0.74}, {-9, 1.16}, {0, 1}},
{"Film Noir", {-2, 0.83}, {5, 1.25}, {-6, 0.74}, {12, 0.65}, {0, 1}, {0, 1}},
{"Jazz for dummies", {3, 0.54}, {5, 0.88}, {-16, 1}, {-10, 1}, {0, 1}, {0, 1}},
{"Major", {4, 1}, {7, 1}, {0, 1}, {0, 1}, {0, 1}, {0, 1}},
{"House for to go", {3, 1}, {7, 1}, {-12, 1}, {0, 1}, {0, 1}, {0, 1}},
{"Fifth", {0, 1}, {7, 1}, {0, 1}, {0, 1}, {0, 1}, {0, 1}},
{"Fourth", {0, 1}, {5, 1}, {0, 1}, {0, 1}, {0, 1}, {0, 1}},
{"Grandiosa", {-12, 1}, {-24, 1.33}, {12, 1.41}, {7, 1}, {0, 1}, {0, 1}}
}
for i=1,6 do
shift[i] = Knob("Shift"..tostring(i), 0, -36, 36, true)
end
for i=1,6 do
velocity[i] = Knob("Velocity_"..tostring(i), 1, 0.01, 2)
end
local presetNames = {}
for i, preset in ipairs(presets) do
presetNames[i] = preset[1]
end
presetMenu = Menu("Presets", presetNames)
presetMenu.persistent = false -- engine does not restore it; we do it ourselves
presetMenu.changed = function(self)
for i=1,6 do
shift[i].value = presets[self.value][i+1][1]
velocity[i].value = presets[self.value][i+1][2]
end
end
presetMenu:changed()
-- Persist only the menu's visual index. The knobs round-trip themselves
-- via .persistent = true. setValue(idx, false) restores the visible
-- selection without firing .changed (which would clobber the knobs).
function onSave()
return { preset = presetMenu.value }
end
function onLoad(data)
if data.preset then
presetMenu:setValue(data.preset, false)
end
end
function onNote(e)
local done = {} -- store already played notes in order to avoid redundancy
for i=1,6 do
if not done[shift[i].value] then
playNote(e.note + shift[i].value, math.min(127, e.velocity*velocity[i].value))
done[shift[i].value] = true;
end
end
end
function onRelease()
-- eat event, release is automatic with playNote
end
Knob widget.
Definition ui.cpp:1525
Menu widget.
Definition ui.cpp:1139
bool persistent
flag to tell if the widget values should be serialized when saving.
Definition ui.cpp:872

⬇ Download Chorder.lua


InvertPitch

Mirror Notes Around a Center Pitch

Mirrors incoming MIDI notes around a user-defined center pitch using a Knob widget. Notes equidistant above the center are mapped below it and vice-versa, creating an intervallic inversion effect.

Demonstrates: onNote callback, Knob widget, pitch arithmetic, playNote

CenterPitch = Knob("Center_Pitch", 60, 0, 127, true)
function onNote(e)
local center = CenterPitch.value
local delta = e.note-center
local note = center - delta
if note>=0 and note<=127 then
playNote(note, e.velocity)
end
end
function onRelease()
-- eat event
end
void onNote(table e)
event callback that will receive all incoming note-on events if defined.
void onRelease(table e)
event callback executed whenever a note off message is received.
function playNote(note, vel, duration, layer, channel, input, vol, pan, tune, slice, oscIndex)
helper function to generate a note event.
Definition api.lua:965

⬇ Download InvertPitch.lua


Keyswitch

Automatic Layer Switching via Keyswitches

Assigns a range of low MIDI keys as keyswitches that select which layer receives subsequent notes. The keyswitch range is coloured red on the keyboard for visual feedback.

Demonstrates: Program.layers, playNote with layer parameter, setKeyColour, onNote / onRelease

local numLayers = #Program.layers -- number of layer in the program
local lastLayerActivated = 1 -- id of the last activated layer
local KSbaseNote = 36 -- MIDI note where keyswitch are located
function onNote(e)
if e.note >= KSbaseNote and e.note < KSbaseNote + numLayers then -- if the note is one of the keyswitch keys
lastLayerActivated = e.note - KSbaseNote + 1 -- update the activated layer id
else
playNote(e.note, e.velocity, -1, lastLayerActivated) -- not a keyswitch key so we play the note on the activated layer
end
end
function onRelease(e)
-- eat event as release is done automatically by playNote
end
for i=KSbaseNote, KSbaseNote+numLayers-1 do
setKeyColour(i, "FF0000") -- colorize keyswitch keys in red
end
A Patch that represents a monotimbral instrument.
Definition Engine.cpp:257
table layers
Layer list for this Program (1-indexed, use #Program.layers to get the count)
Definition Engine.cpp:266
function setKeyColour(note, colour)
customize the keyboard colours.
Definition ui.lua:50

⬇ Download Keyswitch.lua


Latch

Latch / Hold with mid-hold toggle safety

Latches incoming notes: while Latch is on, played notes keep sounding after key-up. A second key-down on the same note clears it. The Clear button releases everything at once. The point of this example is the bookkeeping pattern that prevents stuck and orphan notes when the user toggles Latch mid-hold:

  • The behaviour at note-on (forward immediately vs. forward and hold) depends on the current Latch state.
  • The matching note-off must do the right thing for THIS note's press, not for the current Latch state — otherwise toggling Latch while a key is held leaks voices. The fix is to remember the per-note decision in a latchedAtPress table at the time of the press, then mirror it at release. Any callback whose action depends on a mutable state should follow this pattern.

Demonstrates: OnOffButton, Button, postEvent, releaseVoice, per-note bookkeeping

LatchOn = OnOffButton{"LatchOn", false,
displayName = "Latch",
tooltip = "When on, notes sustain after key-up"}
ClearAll = Button{"ClearAll",
displayName = "Clear",
tooltip = "Release every latched note"}
-- For each note, store the voiceId of the playing voice when it was latched.
-- Without this we cannot release latched voices on demand.
local latchedVoice = {} -- note -> voiceId
-- Per-note flag: was Latch on at the moment of the press?
-- The release must mirror that decision regardless of the Latch state now.
local latchedAtPress = {}
local function clearAll()
for note, vid in pairs(latchedVoice) do
latchedVoice[note] = nil
latchedAtPress[note] = nil
end
end
function onNote(e)
-- Re-pressing a latched note: clear it, do not forward.
if latchedVoice[e.note] then
releaseVoice(latchedVoice[e.note])
latchedVoice[e.note] = nil
latchedAtPress[e.note] = nil
return
end
-- New press: always start the voice. We capture its voiceId so we can
-- release it later (either via a re-press, ClearAll, or onRelease).
local vid = postEvent(e)
latchedAtPress[e.note] = LatchOn.value
if LatchOn.value then
latchedVoice[e.note] = vid
end
end
function onRelease(e)
-- Mirror the press: if the press was latched, hold the voice. If the
-- press was a normal pass-through, forward the release so the engine
-- ends the voice cleanly. Note that we never look at LatchOn.value here.
if latchedAtPress[e.note] then
-- The press was latched -> swallow the release; voice keeps sounding.
latchedAtPress[e.note] = nil
return
end
-- The press was unlatched -> release normally.
latchedAtPress[e.note] = nil
end
ClearAll.changed = function(self)
clearAll()
end
-- Turning Latch off mid-hold: release everything currently latched. Notes
-- whose key is still pressed will be released here too, which is the
-- expected musical behaviour ("flush the latch").
LatchOn.changed = function(self)
if not self.value then
clearAll()
end
end
setSize(280, 100)
stateless transient button.
Definition ui.cpp:903
2 states boolean button.
Definition ui.cpp:963
function releaseVoice(voiceId)
release a specific voice by sending it a note off message
Definition api.lua:1042
function postEvent(e, delta)
send a script event back to the script engine event queue.
Definition api.lua:857
void setSize(number w, number h)
set the script UI dimensions explicitly.

⬇ Download Latch.lua


quarterTone

Quarter-Tone Keyboard Mapping

Remaps the standard 12-tone keyboard to a 24-tone quarter-tone scale relative to a selectable root note. Even intervals map directly; odd intervals are detuned by 50 cents using changeTune.

Demonstrates: Menu widget, changeTune, microtonal pitch mapping, modular arithmetic

-- setSize(600,80)
notes = {"C","C#","D","D#","E","F","F#","G","G#","A","A#","B"}
notenames={}
for i=1,128 do
notenames[i] = notes[(1+(i-1)%12)] .. (math.floor(i/12) - 2)
end
Root = Menu("root", notenames)
Root.value = 60+1
function onNote(e)
local root = Root.value-1
local note = e.note
local velocity = e.velocity
local detune = 0
if (e.note-root)%2 == 0 then -- no detune
note = root + math.floor((e.note-root)/2)
else
if e.note > root then
note = math.floor((e.note-root)/2) + root + 1
detune = -0.5 -- minus 50 cents
elseif e.note < root then
note = math.floor((e.note-root)/2) + root - 1
detune = 0.50 -- plus 50 cents
end
end
local id = playNote(note, velocity)
if detune ~= 0 then
changeTune(id, detune)
end
end
function onRelease(e)
-- release is automatic
end
function changeTune(voiceId, shift, relative, immediate)
change the tuning of specific voice in (fractionnal) semitones.
Definition api.lua:488

⬇ Download quarterTone.lua


TimbreShifting

Borrow Neighbouring Keygroup Timbres

Shifts the played note into a neighbouring keygroup and retunes it back to the original pitch, effectively borrowing that keygroup's timbre. The shift amount is controlled by a bipolar Knob.

Demonstrates: playNote, changeTune with absolute flag, timbral manipulation

shiftKnob = Knob("shift", 0, -5, 5, true)
function onNote(e)
local shift = shiftKnob.value
local note = e.note + shift
local id = playNote(note, e.velocity, -1)
changeTune(id, -shift, false, true)
end
function onRelease()
-- eat event release is automatic in playNote
end

⬇ Download TimbreShifting.lua


Voice Effects

Ensemble

Ensemble with Pan and Time Spread

Similar to Unison but adds per-voice pan spread and staggered onset timing to simulate an ensemble. Each voice is shifted, retuned, panned, and delayed by a small jitter amount.

Demonstrates: playNote, changeTune, changePan, changeVolume, wait

PanSpread = Knob("PanSpread", 1.0, 0, 1)
TimeSpread = Knob("TimeSpread", 0.5, 0, 1)
shifts = {0, 1, -1, 2, -2}
--shifts = {0, 1, -1, 2, -2, 3, -3}
--shifts = {0, 1, -1, 2, -2, 3, -3, 4, -4}
function onNote(e)
local panSpread = PanSpread.value
local timeSpread = TimeSpread.value
local numShifts = #shifts
for i=1,numShifts do
local shift = shifts[i]
local note = e.note + shift
local id = playNote(note, e.velocity)
local tune = -shift
changeTune(id, tune) -- repitch the note
changeVolume(id, 1/math.sqrt(numShifts))
changePan(id, panSpread * shifts[i] / 2)
wait(timeSpread*10) -- 10 ms jitter
end
end
function onRelease()
-- eat event release is automatic in playNote
end
function wait(ms)
suspend the current thread callback execution for the given number of milliseconds.
Definition wrapper.lua:39
function changeVolume(voiceId, gain, relative, immediate)
changes a voice's volume.
Definition api.lua:530
function changePan(voiceId, pan, relative, immediate)
changes the pan position of a specific note event.
Definition api.lua:515

⬇ Download Ensemble.lua


tremolo

Amplitude and Pan LFO

Modulates volume and pan of each voice with a sine-wave LFO whose frequency scales with MIDI note number. The volume oscillates between 0 and 1 while pan follows a cosine (90-degree phase offset).

Demonstrates: changeVolume, changePan, isNoteHeld, wait, postEvent

lfoFreq = Knob("freq", 4.0, 0, 10) -- 4 Hz
function onNote(e)
local id = postEvent(e)
local duration = 0 -- in seconds
local step = 5 -- ms
while isNoteHeld() do
local freq = lfoFreq.value * e.note/128.0
local volume = 0.5 * ( 1 + math.sin(2 * math.pi * freq * duration))
local pan = math.sin(2 * math.pi * freq * duration + math.pi/2)
changeVolume(id, volume);
changePan(id, pan);
wait(step)
duration = duration + step/1000.0
end
end
function isNoteHeld()
return true is the note that created this callback is still held.
Definition api.lua:821

⬇ Download tremolo.lua


Unison

Detuned Unison Voices

Stacks multiple detuned copies of the incoming note. The number of voices and the maximum detune spread are adjustable. Alternating voices are tuned sharp and flat in increasing amounts, and volume is auto-scaled by the square root of the voice count.

Demonstrates: playNote, Knob, changeTune, changeVolume

Voices = Knob("numVoices", 5, 2, 10, true)
Detune = Knob("Detune", 10.0, 0, 40) -- cents
function onNote(e)
local nVoices = Voices.value
local detune = Detune.value
for i=1,nVoices do
local note = e.note
local i2 = math.floor(i/2)
local rest = i%2
local tune = detune * i2 / 100.0
if rest == 1 then
tune = tune * -1.0
end
local id = playNote{e.note, e.velocity, vol=1/math.sqrt(nVoices), tune=tune}
end
end
function onRelease()
-- eat event release is automatic in playNote
end

⬇ Download Unison.lua


vibrato

Per-Voice Pitch Vibrato

Applies a sine-wave pitch vibrato to each voice independently. Frequency and depth are adjustable in real time via Knob widgets. The LFO runs inside the onNote callback using a wait loop gated by isNoteHeld.

Demonstrates: changeTune, isNoteHeld, wait, per-voice LFO, Knob

Freq = Knob("Freq", 4.0, 0, 10) -- 4 Hz
Depth = Knob("Depth", 0.5, 0, 1)
local step = 5 -- ms
function onNote(e)
local id = postEvent(e) -- duration is omitted
local phase = 0
while isNoteHeld() do
local depth = Depth.value
local freq = Freq.value
local modulation = depth * math.sin(2 * math.pi * phase)
changeTune(id, modulation)
wait(step)
phase = phase + (step/1000.0) * freq
end
end

⬇ Download vibrato.lua


VoiceTracker

Track and Manipulate Active Voices

Maintains a table of all active voice IDs keyed by note number. Applies a note-number-based pan spread: low notes left, high notes right. Demonstrates the voice tracking pattern used in many real-world scripts.

Demonstrates: postEvent, changePan, voice ID tracking, onNote / onRelease

local voices = {} -- track active voices: voices[note] = voiceId
function onNote(e)
local id = postEvent(e)
voices[e.note] = id
-- pan spread: note 0 = hard left, note 127 = hard right
local pan = (e.note / 127) * 2 - 1
changePan(id, pan)
end
function onRelease(e)
voices[e.note] = nil
end

⬇ Download VoiceTracker.lua


Performance & Articulation

legato

Legato with Crossfade and Retrigger

Implements monophonic legato by crossfading between overlapping notes. New notes fade in from a sample offset while the previous note fades out. An optional retrigger mode re-voices the last held note on release.

Demonstrates: postEvent, fadein, fadeout, setSampleOffset, releaseVoice, note stack

local notes = {}
Fade = Knob("fade", 40, 10, 100)
Retrigger = OnOffButton("retrigger", false)
local sampleOffset = 50 -- ms
function onNote(e)
if #notes > 0 then
local fadetime = Fade.value
fadeout(notes[#notes].id, fadetime, true)
local id = postEvent(e)
table.insert(notes, e)
setSampleOffset(id, sampleOffset)
fadein(id, fadetime, true)
else
local id = postEvent(e)
table.insert(notes, e)
end
end
function onRelease(e)
for i,noteon in ipairs(notes) do
if noteon.note == e.note then
table.remove(notes, i)
releaseVoice(noteon.id)
local shouldRetrigger = Retrigger.value and #notes > 0 and i > #notes
if shouldRetrigger then
local noteon = notes[#notes]
local id = playNote(noteon.note, noteon.velocity)
noteon.id = id
local fadetime = Fade.value
setSampleOffset(id, sampleOffset)
fadein(id, fadetime, true)
end
break
end
end
end
bool value
the button's state
Definition ui.cpp:981
function fadeout(voiceId, duration, killVoice, reset, layer)
starts a volume fade-out.
Definition api.lua:609
function setSampleOffset(voiceId, value)
changes a sample starting point in milliseconds.
Definition api.lua:569
function fadein(voiceId, duration, reset, layer)
starts a volume fade-in for a specific voice.
Definition api.lua:643

⬇ Download legato.lua


MappingArticulations

Switching mappings and articulations at run time

Drives a SampleMappingOscillator two ways: one menu swaps the whole mapping — the expensive, disk-bound switch, with a progress bar — and another picks the articulation within it, which costs nothing because it only changes the dim1 coordinate the next note-on carries. The mapping load is deliberately not in onInit. A program restores its own mapping with the preset, so there is nothing to load or wait for at init; the script loads a mapping only when the player switches to another one. That is also why the menu's callback opens with the userReady guard that during preset restore too, and re-loading there would re-hit the disk for a mapping the program already holds. Three details are specific to sample mapping, and the

  • the status waits, the callback reports. getLoadingStatus() drops to 0 for the load and climbs back to 1, which both paces the wait and feeds the bar; loadMapping's callback only says whether the mapping loaded at all.
  • **dim1 needs postEvent.** playNote has no parameter for it, so a note played through it always lands on layer 0.
  • purging is per layer. The generic purge does nothing on this oscillator; purgeZones(dim1, -1) frees one whole layer. oscillator is not offered in the element browser. It ships with an empty mapping path, so it loads silently and waits for you to point the Instrument menu at your own mapping files — Mappings/<name>.dmap, next to the patch, with layers ordered like ARTICULATIONS. No mapping ships with the documentation.

Demonstrates: osc:loadMapping, osc:purgeZones, postEvent with dim1, Menu, OnOffButton, spawn

local INSTRUMENTS = { "Violin", "Viola", "Cello" }
-- Menu entry i addresses layer dim1 = i - 1: a layer's dim1 is its position in
-- the mapping file, counted from 0.
local ARTICULATIONS = { "Sustain", "Staccato", "Pizzicato" }
local osc = Program.layers[1].keygroups[1].oscillators[1]
-- Flipped in onInit, once preset state has been restored. Any .changed that hits
-- the disk must check it.
local userReady = false
-- True while a mapping is being replaced: notes played against a half-loaded
-- mapping are silent, so we stop triggering rather than drop them on the floor.
local loading = false
local currentDim1 = 0
local instrumentMenu = Menu{"Instrument", INSTRUMENTS,
backgroundColour = "333333",
textColour = "white",
}
local artMenu = Menu{"Articulation", ARTICULATIONS,
backgroundColour = "333333",
textColour = "white",
}
local economy = OnOffButton{"Economy", false}
local progress = Slider{"loading", 0, 0, 1}
progress.visible = false
-- Keep only the selected articulation in memory when economy is on. A negative
-- dim2 means "the whole dim1 layer".
local function applyEconomy()
for i = 1, #ARTICULATIONS do
local dim1 = i - 1
if economy.value and dim1 ~= currentDim1 then
osc:purgeZones(dim1, -1)
else
osc:unpurgeZones(dim1, -1)
end
end
end
local function loadInstrument(name)
if not userReady then return end
-- Silence the layer before the mapping under it is replaced.
postEvent{ type = Event.ControlChange, controller = 120, value = 0, layer = 1 }
loading = true
progress.visible = true
progress:setValue(0, false)
-- The callback answers one question: did the mapping load at all. The engine
-- has already told the user which file it could not find.
osc:loadMapping("Mappings/" .. name .. ".dmap", function(task)
if not task.success then print("could not load", name) end
end)
-- Waiting is the status's job, and it needs its own thread: a widget callback
-- cannot wait() itself.
spawn(function()
while osc:getLoadingStatus() < 1.0 do
progress:setValue(osc:getLoadingStatus(), false)
wait(30)
end
progress.visible = false
loading = false
applyEconomy()
end)
end
instrumentMenu.changed = function(self)
loadInstrument(self.selectedText)
end
artMenu.changed = function(self)
currentDim1 = self.selected - 1
if not userReady then return end
applyEconomy()
end
economy.changed = function(self)
if not userReady then return end
applyEconomy()
end
function onNote(e)
if loading then return end
-- Adding dim1 to the incoming event is enough; postEvent turns it into a
-- mapping dispatch. dim2 is left out so the round robin picks the variant.
e.dim1 = currentDim1
end
function onInit()
currentDim1 = artMenu.selected - 1
if osc == nil or osc.type ~= "SampleMappingOscillator" then
print("no Sample Mapping oscillator in the first keygroup")
return
end
-- The program's own mapping is already loaded: nothing to wait for here.
userReady = true
end
A multi-zone sample dispatcher.
Definition Engine.cpp:450
void onInit()
initial callback that is called just after the script initialisation if the script was successfully a...
function spawn(fun,...)
Launch a function in a separate parallel execution thread (deferred execution)
Definition api.lua:1201
void makePerformanceView()
make this script User Interface visible in performance view.
Definition ui.lua:107

⬇ Download MappingArticulations.lua ⬇ Download Bundled Sample Mapping patch (load in Falcon)


monoBassLine

Monophonic Bass Synth with Sequencer

A complete instrument script featuring oscillator mixing, amplitude ADSR, resonant filter with LFO modulation, and an 8-step pitch sequencer. Demonstrates full UI layout with multiple Panels, Tables, Menus, and Knobs, as well as Mapper and Unit types for automatic value scaling and display.

Demonstrates: Panel, Table, Menu, Knob, Mapper, Unit, waitBeat, setSize, makePerformanceView

--------------------------------------------------------------------------------
-- init direct access to most used engine nodes
--------------------------------------------------------------------------------
local keygroup = Program.layers[1].keygroups[1]
local ampEnv = Program.layers[1].keygroups[1].modulations["Amp. Env"]
local oscillators = keygroup.oscillators
local filter = keygroup.inserts[1]
--------------------------------------------------------------------------------
--------------------------------------------------------------------------------
local oscPanel = Panel("Oscs")
local oscVolume = oscPanel:Knob("Osc", 1, 0, 1)
oscVolume.fillColour = "lightgrey"
oscVolume.outlineColour = "orange"
oscVolume.mapper = Mapper.Cubic
oscVolume.unit = Unit.LinearGain
oscVolume.changed = function(self)
oscillators[1]:setParameter("Gain", self.value)
end
oscVolume:changed()
local subVolume = oscPanel:Knob("Sub", 0, 0, 1)
subVolume.fillColour = "lightgrey"
subVolume.outlineColour = "orange"
subVolume.mapper = Mapper.Cubic
subVolume.unit = Unit.LinearGain
subVolume.changed = function(self)
oscillators[2]:setParameter("Gain", self.value)
end
subVolume:changed()
local noiseVolume = oscPanel:Knob("Noise", 0, 0, 1)
noiseVolume.fillColour = "lightgrey"
noiseVolume.outlineColour = "orange"
noiseVolume.mapper = Mapper.Cubic
noiseVolume.unit = Unit.LinearGain
noiseVolume.changed = function(self)
oscillators[3]:setParameter("Gain", self.value)
end
noiseVolume:changed()
--------------------------------------------------------------------------------
-- Amplitude ADSR
--------------------------------------------------------------------------------
local adsrPanel = Panel("AmpEnv")
local attack = adsrPanel:Knob("Attack", 0.009, 0.009, 1.06)
attack.outlineColour = "magenta"
attack.mapper = Mapper.Exponential
attack.unit = Unit.Seconds
attack.changed = function(self)
ampEnv:setParameter("AttackTime", self.value)
end
attack:changed()
local decay = adsrPanel:Knob("Decay", 0.174, 0.174, 2.477)
decay.outlineColour = "magenta"
decay.mapper = Mapper.Exponential
decay.unit = Unit.Seconds
decay.changed = function(self)
ampEnv:setParameter("DecayTime", self.value)
end
decay:changed()
local sustain = adsrPanel:Knob("Sustain", 1, 0, 1)
sustain.outlineColour = "magenta"
sustain.unit = Unit.PercentNormalized
sustain.changed = function(self)
ampEnv:setParameter("SustainLevel", self.value)
end
sustain:changed()
local release = adsrPanel:Knob("Release", 0.05, 0.05, 5.028)
release.outlineColour = "magenta"
release.mapper = Mapper.Exponential
release.unit = Unit.Seconds
release.changed = function(self)
ampEnv:setParameter("ReleaseTime", self.value)
end
release:changed()
--------------------------------------------------------------------------------
-- Filter
--------------------------------------------------------------------------------
local filterPanel = Panel("Filter")
-- widget constructors also accept a single table, mixing positional and named
-- arguments (similar to Python keyword arguments)
local cutoff = filterPanel:Knob{
"Cutoff", 20000, 20, 20000,
mapper = Mapper.Exponential,
unit = Unit.Hertz,
fillColour = "lightgrey",
outlineColour = "yellow",
changed = function(self)
filter:setParameter("Freq", self.value)
end
}
cutoff:changed()
local reso = filterPanel:Knob("Reso", 0, 0, 1)
reso.fillColour = "lightgrey"
reso.outlineColour = "yellow"
reso.unit = Unit.PercentNormalized
reso.changed = function(self)
filter:setParameter("Q", self.value)
end
reso:changed()
local lfoToCutoff = filterPanel:Knob("lfoToCutoff", 0, -1, 1)
lfoToCutoff.fillColour = "lightgrey"
lfoToCutoff.outlineColour = "yellow"
lfoToCutoff.unit = Unit.PercentNormalized
lfoToCutoff.changed = function(self)
filter:getParameterConnections("Freq")[1]:setParameter("Ratio", self.value)
end
lfoToCutoff:changed()
--------------------------------------------------------------------------------
-- Mini bass line Sequencer
--------------------------------------------------------------------------------
local seqPanel = Panel("Sequencer")
local resolutions = {0.5, 0.25, 0.125}
local resolutionNames = {"1/8", "1/16", "1/32"}
local numSteps = 8
local steps = seqPanel:Table("pitch", numSteps, 0, -12, 12, true)
local res = seqPanel:Menu{"Resolution", resolutionNames, selected=2}
-- displayText overrides the automatic unit display with a custom string,
-- useful when the value needs domain-specific formatting
local gate = seqPanel:Knob("Gate", 1, 0, 1)
gate.changed = function(self)
self.displayText = string.format("%d%%", self.value * 100)
end
gate:changed()
res.backgroundColour = "black"
res.textColour = "cyan"
res.arrowColour = "grey"
res.outlineColour = "#1fFFFFFF" -- transparent white
local positionTable = seqPanel:Table("position", numSteps, 0, 0, 1, true)
positionTable.enabled = false
positionTable.persistent = false
function clearPosition()
for i = 1, numSteps do
positionTable:setValue(i, 0)
end
end
local arpId = 0
local heldNotes = {}
function arpeg(arpId_)
local index = 0
while arpId_ == arpId do
local e = heldNotes[#heldNotes]
local p = resolutions[res.value]
local note = e.note + steps:getValue(index+1)
playNote(note, e.velocity, beat2ms(gate.value*p))
positionTable:setValue((index - 1 + numSteps) % numSteps + 1, 0)
positionTable:setValue((index % numSteps)+1, 1)
index = (index+1) % numSteps
waitBeat(p)
end
end
--------------------------------------------------------------------------------
-- callbacks
--------------------------------------------------------------------------------
function onNote(e)
table.insert(heldNotes, e)
if #heldNotes == 1 then
arpeg(arpId)
end
end
function onRelease(e)
for i,v in ipairs(heldNotes) do
if v.note == e.note then
table.remove(heldNotes, i)
if #heldNotes == 0 then
clearPosition()
arpId = arpId + 1
end
break
end
end
end
--------------------------------------------------------------------------------
-- UI positioning
--------------------------------------------------------------------------------
local margin = 10
oscPanel.x = margin
oscPanel.y = margin
oscPanel.width = 400
oscPanel.height = 60
filterPanel.x = oscPanel.x
filterPanel.y = oscPanel.y + oscPanel.height + margin
filterPanel.width = 400
filterPanel.height = 60
adsrPanel.x = filterPanel.x
adsrPanel.y = filterPanel.y + filterPanel.height + margin
adsrPanel.width = 500
adsrPanel.height = 60
seqPanel.x = adsrPanel.x
seqPanel.y = adsrPanel.y + adsrPanel.height + margin
seqPanel.width = 630
seqPanel.height = 150
steps.y = steps.y + 10
steps.width = 500
steps.height = 130
positionTable.x = steps.x
positionTable.y = steps.y - 10
positionTable.width = steps.width
positionTable.height = 10
res.x = steps.x + steps.width + margin
res.y = steps.y
gate.x = steps.x + steps.width + margin
gate.y = steps.y + 70
setSize(650, 380)
makePerformanceView()
Predefined mapper types.
Definition ui.cpp:633
@ Exponential
Exponential mapper, the parameter's range should be strictly positive.
Definition ui.cpp:645
The Synthesis primitive.
Definition Engine.cpp:320
Panel widget.
Definition ui.cpp:1811
Table widget.
Definition ui.cpp:1332
Predefined unit types.
Definition ui.cpp:691
@ Hertz
display Hz symbol.
Definition ui.cpp:718

⬇ Download monoBassLine.lua ⬇ Download Bundled MonoSynth patch (load in Falcon)


portamento

Portamento with Pitch Glide

Creates a smooth pitch glide between consecutive notes using a custom coroutine-based glide function. The outgoing note glides up while the incoming note glides down, producing a continuous portamento effect.

Demonstrates: changeTune, spawn, fadein, fadeout, playNote

local numNotes = 0
local lastid = -1
local lastnote = 0
Fade = Knob("fade", 100, 1, 500)
function glide(id, from, to, duration, period)
duration = duration or 100 -- 100 ms
period = period or 10 -- 10 ms
local doglide = function()
local value = from
local increment = (to - from) * period / duration
local t = 0
local immediate = true
while t < duration do
changeTune(id, value, false, immediate)
immediate = false
wait(period)
value = value + increment
t = t + period
end
changeTune(id, to)
end
_spawn(doglide)
end
function onNote(e)
if numNotes > 0 then
local fadetime = Fade.value
fadeout(lastid, fadetime, true)
glide(lastid, 0, (e.note-lastnote), fadetime)
lastid = playNote(e.note, e.velocity)
--setSampleOffset(lastid, 0.1)
fadein(lastid, fadetime, true)
glide(lastid, (lastnote-e.note), 0, fadetime)
lastnote = e.note
else
lastid = playNote(e.note, e.velocity)
lastnote = e.note
end
numNotes = numNotes + 1
end
function onRelease(e)
numNotes = math.max(0, numNotes - 1)
end

⬇ Download portamento.lua


Sequencing

Arpeggiator

Classic chord arpeggiator (Up / Down / UpDown, octave range)

A simplified port of the built-in UVI arpeggiator: takes chord input, plays a single time-aligned pattern across the held notes, with the standard modes and an octave-range knob. Architecture worth studying:

  1. One shared timeline, not one coroutine per note. A single arp coroutine is spawned on the first note-down and torn down on the last note-up via an epoch token (arpId). Notes added or removed mid-pattern simply update the shared held-notes set; the running loop sees the change on its next step. This is what gives the arp a stable beat grid regardless of how many keys come and go.
  2. Per-note mirror for safe Enable toggle. When Enable is off the script forwards notes (pass-through). When on it swallows them and feeds the arp. The matching release MUST do whatever the press did, regardless of the current Enable state, otherwise toggling Enable mid-hold leaks stuck or orphan voices. We remember the decision per-note in forwarded.

Demonstrates: spawn, waitBeat, beat2ms, postEvent, playNote, OnOffButton, Menu, Knob, per-note bookkeeping

local resolutions = {1.0, 0.5, 1.0/3, 0.25, 1.0/6, 0.125}
local resolutionNames = {"1/4", "1/8", "1/4t", "1/16", "1/8t", "1/32"}
local modes = {"Up", "Down", "UpDown"}
------------------------------------------------------------------------
-- UI
------------------------------------------------------------------------
Enable = OnOffButton{"Enable", true,
displayName = "Arp On",
tooltip = "Off = pass-through"}
Mode = Menu{"Mode", modes,
selected = 1,
displayName = "Mode"}
Speed = Menu{"Speed", resolutionNames,
selected = 2,
displayName = "Speed"}
Octaves = Knob{"Octaves", 1, 1, 4, true,
displayName = "Octaves",
tooltip = "Octaves the pattern spans"}
Gate = Knob{"Gate", 0.8, 0.05, 1.0,
displayName = "Gate"}
------------------------------------------------------------------------
-- Held-note set
-- Tones are kept sorted ascending by pitch so each step indexes into a
-- stable, musical order regardless of the order the user pressed keys.
------------------------------------------------------------------------
local heldByNote = {}
local held = {}
local function rebuildSorted()
local arr = {}
for _, ev in pairs(heldByNote) do arr[#arr + 1] = ev end
table.sort(arr, function(a, b) return a.note < b.note end)
held = arr
end
------------------------------------------------------------------------
-- Per-note bookkeeping (see header comment).
------------------------------------------------------------------------
local forwarded = {}
------------------------------------------------------------------------
-- Arp coroutine: one shared timeline.
-- The arpId epoch token lets older spawns die cleanly when the chord
-- empties or Enable is turned off.
------------------------------------------------------------------------
local arpId = 0
local function noteForStep(step)
local n = #held
if n == 0 then return nil end
local octs = math.max(1, Octaves.value)
local span = n * octs -- total tones across octaves
local m = Mode.value
local idx, octIdx
if m == 1 then -- Up
local s = step % span
idx, octIdx = (s % n) + 1, math.floor(s / n)
elseif m == 2 then -- Down
local s = (span - 1) - (step % span)
idx, octIdx = (s % n) + 1, math.floor(s / n)
else -- UpDown (palindrome)
local cycle = (span > 1) and (2 * (span - 1)) or 1
local s = step % cycle
if s >= span then s = cycle - s end
idx, octIdx = (s % n) + 1, math.floor(s / n)
end
return held[idx], octIdx
end
local function runArp(myId)
local step = 0
while myId == arpId do
local tone, octIdx = noteForStep(step)
if tone then
local note = tone.note + 12 * octIdx
if note > 127 then note = 127 end
local beats = resolutions[Speed.value]
playNote{note = note,
velocity = tone.velocity,
duration = beat2ms(beats) * Gate.value}
waitBeat(beats)
else
waitBeat(0.0625) -- idle yield
end
step = step + 1
end
end
------------------------------------------------------------------------
-- Callbacks
------------------------------------------------------------------------
function onNote(e)
if not Enable.value then
forwarded[e.note] = true
return
end
forwarded[e.note] = nil
heldByNote[e.note] = {note = e.note, velocity = e.velocity}
rebuildSorted()
if #held == 1 then -- first key down: start the arp
arpId = arpId + 1
spawn(runArp, arpId)
end
end
function onRelease(e)
-- Mirror the press decision regardless of the current Enable state.
if forwarded[e.note] then
forwarded[e.note] = nil
return
end
if heldByNote[e.note] then
heldByNote[e.note] = nil
rebuildSorted()
if #held == 0 then
arpId = arpId + 1 -- last key up: kill the arp
end
end
end
-- Turning Enable off mid-chord silences the arp loop. Notes already
-- forwarded (Enable was off at their press) keep their entry in
-- `forwarded` and will release correctly on key-up.
Enable.changed = function(self)
if not self.value then
arpId = arpId + 1
held = {}
heldByNote = {}
end
end
setSize(720, 140)
@ PercentNormalized
display % symbol but actual value is in the 0..1 range
Definition ui.cpp:706
function beat2ms(beat)
Convert beat duration to milliseconds based on the current tempo.
Definition conversions.lua:38
function waitBeat(beat)
Suspend execution for a tempo-synchronized duration in beats.
Definition conversions.lua:142

⬇ Download Arpeggiator.lua


DrumSequencer

Eight-Track Drum Grid With Mixed Step Resolutions

Eight drum tracks on one bar, each with its own MIDI note, mute, solo and a grid you draw velocities into. Three lanes per track (velocity, pan, tune), a write resolution from 1/4 down to 1/64T, swing, per-track pattern tools, pattern files and MIDI export. See StepSequencer for the same idea reduced to its smallest possible form. Two mechanisms here are worth studying before writing a sequencer of your own.

  1. The write grid. Each track and lane owns TWO Tables sharing the same bounds. Steps holds the pattern at the finest resolution the sequencer supports (96 steps per bar) and is never clicked. Paint sits on top of it, is resized to the resolution the user draws at, and writes into Steps. That indirection is what lets 1/4 and 1/64T hits coexist in one sequence: a coarse edit still lands on the fine grid, so it never destroys what was drawn at a finer resolution. Drawing goes through writeCell() whatever triggers it, the mouse or a tool.
  2. The clock. A micro-thread walks the fine grid and is cancelled by bumping a generation counter, so two loops can never end up running at once, however fast the transport is toggled.

Demonstrates: Table, spawn, waitBeat, playNote, saveData, createMidiFile, Euclidean rhythms

--------------------------------------------------------------------------------
-- Configuration
--------------------------------------------------------------------------------
local NUM_TRACKS = 8
-- Short on purpose: the track column is kept narrow so the grid gets the width instead.
local TRACK_NAMES = {"Kick", "Snare", "Clap", "HH", "HH ped", "HH open", "Crash", "Ride"}
local DEFAULT_NOTES = {36, 38, 39, 42, 44, 46, 49, 51} -- General MIDI drum map
local TRACK_COLOURS = {"ed6b51", "bb8d00", "b5b107", "81c781", "7fe0e0", "02b0ff", "a398e8",
"be88cc"}
-- 96 steps per bar is the smallest grid that divides every write resolution below into a whole
-- number of steps. That is the whole reason resolutions can be mixed in one pattern.
local STEPS_PER_BAR = 96
local NUM_BARS = 1
local NUM_STEPS = STEPS_PER_BAR * NUM_BARS
local STEP_BEATS = 4 / STEPS_PER_BAR -- one storage step, in beats
local RESOLUTION_NAMES = {"1/4", "1/8", "1/8T", "1/16", "1/16T", "1/32", "1/32T", "1/64T"}
local RESOLUTION_CELLS = { 4, 8, 12, 16, 24, 32, 48, 96}
local DEFAULT_RESOLUTION = 4 -- 1/16
-- Lane 1 is the trigger lane: a cell drawn there fires one note. The others are continuous, so a
-- cell drawn there holds its value for its whole span and the note picks up whatever value sits
-- under it, however coarsely it was drawn.
local LANE_NAMES = {"Velocity", "Pan", "Tune"}
local LANE_MIN = {0, -100, -12}
local LANE_MAX = {127, 100, 12}
local LANE_CONTINUOUS = {false, true, true}
local NUM_LANES = #LANE_NAMES
local SWING_NAMES = {"1/16", "1/8", "1/4"}
local SWING_STEPS = {STEPS_PER_BAR / 16, STEPS_PER_BAR / 8, STEPS_PER_BAR / 4}
local DEFAULT_SWING = 2 -- 1/8
local DEFAULT_TRIGGER_NOTE = 60 -- above the drum map, so it cannot collide with a track note
local TRIGGER_KEY_COLOUR = "#1DA462"
local EUCLID_VELOCITY = 100
local PATTERN_EXT = "json" -- saveData writes JSON, so name the file for what it is
-- The panel is painted flat instead of being left on the host's textured default, because two
-- things need its exact value: the grid lines are a shade lighter than it, and the gaps between
-- rows are masked back to it. The grid line colour is the factory script's own; the track
-- separators sit a step brighter again, since they carry the coarser structure of the two.
local PANEL_COLOUR = "232326"
local GRID_LINE_COLOUR = "2b2b2f"
local SEPARATOR_COLOUR = "35353a"
setBackgroundColour(PANEL_COLOUR)
-- Resting value of a paint cell, parked just above zero rather than at zero: `changed` only fires
-- when a value really changes, so from zero a cell could never be dragged to zero and erasing a
-- step would silently do nothing.
local PAINT_IDLE = 0.001
--------------------------------------------------------------------------------
-- Helpers
--------------------------------------------------------------------------------
local NOTE_LETTERS = {"C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"}
-- "C-2" .. "G8": one list shared by every menu that picks a note.
local NOTE_NAMES = {}
for note = 0, 127 do
NOTE_NAMES[note + 1] = NOTE_LETTERS[note % 12 + 1] .. (math.floor(note / 12) - 2)
end
local function stepId(lane, track)
return "Steps_" .. lane .. "_" .. track
end
--------------------------------------------------------------------------------
-- Layout
--------------------------------------------------------------------------------
-- 480 px is the height ceiling, so the rows get the space and everything else is trimmed to fit:
-- eight 43 px rows plus a transport bar and a utility bar land on exactly 480.
local MARGIN = 10 -- left edge shared by the track names, the transport and the utility bar
local CAPTION_Y = 6
local CONTROL_Y = 20
local PLAYHEAD_Y = 56
local ROW_Y = 64
local ROW_H = 43 -- the factory script's track height: tall enough for the bars to be worth reading
local ROW_GAP = 5
local ROW_PITCH = ROW_H + ROW_GAP
local GRID_X = 230
local GRID_W = 480 -- 96 storage steps at exactly 5 px each
local GRID_H = (NUM_TRACKS - 1) * ROW_PITCH + ROW_H
local STEP_W = GRID_W / NUM_STEPS
local UTILITY_Y = ROW_Y + GRID_H + 6 -- 449 with 8 tracks: the bar rides just under the last row
local function caption(text, x, w)
return Label{text, bounds = {x, CAPTION_Y, w, 12}, fontSize = 10, textColour = "888888",
persistent = false, interceptsMouseClicks = false}
end
--------------------------------------------------------------------------------
-- Transport bar
--------------------------------------------------------------------------------
local Play = OnOffButton{"Play", false, bounds = {MARGIN, CONTROL_Y, 56, 24},
displayName = "Play", tooltip = "Start or stop the sequence", persistent = false}
caption("TRIGGER", 76, 78)
local TriggerNote = Menu{"Trigger_Note", NOTE_NAMES, DEFAULT_TRIGGER_NOTE + 1,
bounds = {76, CONTROL_Y + 1, 78, 22}, showLabel = false,
tooltip = "Key that starts and stops the sequence"}
caption("WRITE GRID", 164, 78)
local Resolution = Menu{"Write_Resolution", RESOLUTION_NAMES, DEFAULT_RESOLUTION,
bounds = {164, CONTROL_Y + 1, 78, 22}, showLabel = false, persistent = false,
tooltip = "Resolution you draw at. The pattern itself is always stored at 1/64T."}
caption("LANE", 252, 78)
local Lane = Menu{"Lane", LANE_NAMES, 1, bounds = {252, CONTROL_Y + 1, 78, 22},
showLabel = false, persistent = false, tooltip = "Which lane the grid and the tools edit"}
caption("SWING DIV", 340, 68)
local SwingDivision = Menu{"Swing_Division", SWING_NAMES, DEFAULT_SWING,
bounds = {340, CONTROL_Y + 1, 68, 22}, showLabel = false,
tooltip = "Period over which swing pushes and pulls"}
caption("SWING", 418, 40)
local Swing = Knob{"Swing", 0, 0, 1, bounds = {418, CONTROL_Y - 2, 34, 34}, showLabel = false,
displayName = "Swing", tooltip = "Swing amount", unit = Unit.PercentNormalized}
caption("GATE", 470, 40)
local Gate = Knob{"Gate", 0.5, 0.05, 4, bounds = {470, CONTROL_Y - 2, 34, 34}, showLabel = false,
displayName = "Gate", tooltip = "Note length, as a proportion of a 1/16 note",
unit = Unit.PercentNormalized}
caption("DRUM SEQUENCER KISS", 560, 144)
--------------------------------------------------------------------------------
-- Utility bar
--------------------------------------------------------------------------------
-- Commands, not state: none of these are persistent.
local SavePattern = Button{"Save_Pattern", bounds = {MARGIN, UTILITY_Y, 64, 24},
displayName = "Save", tooltip = "Write the pattern to a file", persistent = false}
local LoadPattern = Button{"Load_Pattern", bounds = {78, UTILITY_Y, 64, 24},
displayName = "Load", tooltip = "Read a pattern back from a file", persistent = false}
local ExportMidi = Button{"Export_Midi", bounds = {146, UTILITY_Y, 88, 24},
displayName = "MIDI file", tooltip = "Render the velocity lane to a MIDI file",
persistent = false}
local MidiDrag = Button{"Midi_Drag", bounds = {238, UTILITY_Y, 88, 24},
displayName = "Drag MIDI", tooltip = "Drag this into your host", persistent = false,
visible = false}
-- The exported file stops matching as soon as anything it bakes in is edited, so hide the drag
-- source again rather than let it hand out something stale.
local function invalidateMidi()
MidiDrag.visible = false
end
--------------------------------------------------------------------------------
-- Playhead
--------------------------------------------------------------------------------
-- One cell per 1/16 note rather than one per storage step: the position is just as readable and
-- the widget is touched 16 times a bar instead of 96.
local PLAYHEAD_CELLS = 16 * NUM_BARS
local PLAYHEAD_SPAN = NUM_STEPS / PLAYHEAD_CELLS
local Playhead = Table{"Playhead", PLAYHEAD_CELLS, 0, 0, 1, true,
bounds = {GRID_X, PLAYHEAD_Y, GRID_W, 5}, enabled = false, persistent = false,
fillStyle = "solid", sliderColour = "#dcdcdc", backgroundColour = "#00000000"}
--------------------------------------------------------------------------------
-- Grid
--------------------------------------------------------------------------------
-- The write grid is drawn by thin vertical bars sitting behind the pattern, as the factory script
-- does it: one per storage step, shown only on the boundaries of the current write resolution.
--
-- A Table's inner edges cannot stand in for these -- with its cells at rest there is no filled
-- slider for an edge to be drawn around -- and the paint overlay cannot draw them either, since
-- it is hidden outright (see below).
local writeGridBars = {}
for step = 1, NUM_STEPS do
local onBeat = (step - 1) % (STEPS_PER_BAR / 4) == 0 -- beats get a wider line
writeGridBars[step] = Label{"", bounds = {GRID_X + (step - 1) * STEP_W, ROW_Y,
onBeat and 2 or 1, GRID_H}, backgroundColour = GRID_LINE_COLOUR, persistent = false,
interceptsMouseClicks = false}
end
-- Close the grid on the right: the end of the bar is a cell boundary at every resolution, and a
-- beat boundary at that, so it gets the wide line the other beats get.
Label{"", bounds = {GRID_X + GRID_W - 2, ROW_Y, 2, GRID_H}, backgroundColour = GRID_LINE_COLOUR,
persistent = false, interceptsMouseClicks = false}
-- Those bars run the whole height, so they would tie the eight rows into one block. Each gap
-- between rows is masked back to the panel colour to break them apart, then given a single rule
-- running the whole width of the script, track column included, so a row reads as one band from
-- its name across to the far end of its grid. The mask is what keeps that rule clean: without it
-- the vertical bars would cross it every few pixels.
for gap = 1, NUM_TRACKS - 1 do
local y = ROW_Y + (gap - 1) * ROW_PITCH + ROW_H
Label{"", bounds = {GRID_X, y, GRID_W, ROW_GAP},
backgroundColour = PANEL_COLOUR, persistent = false, interceptsMouseClicks = false}
Label{"", bounds = {MARGIN, y + 2, GRID_X + GRID_W - MARGIN, 1},
backgroundColour = SEPARATOR_COLOUR, persistent = false, interceptsMouseClicks = false}
end
local Name, Mute, Solo, Note, Tools = {}, {}, {}, {}, {}
local Steps, Paint = {}, {}
local suspendPaint = false
local function paintCells()
return RESOLUTION_CELLS[Resolution.value] * NUM_BARS
end
-- The single write path into the pattern. A trigger lane puts the value on the first storage step
-- of the cell and clears the rest, or a 1/4 note would turn into `span` consecutive hits; a
-- continuous lane fills the whole span so the value is found under every step it covers.
local function writeCell(lane, track, cell, cells, value)
local span = NUM_STEPS / cells
local first = (cell - 1) * span + 1
for step = first, first + span - 1 do
Steps[lane][track]:setValue(step, (LANE_CONTINUOUS[lane] or step == first) and value or 0)
end
end
local function readCell(lane, track, cell, cells)
return Steps[lane][track]:getValue((cell - 1) * NUM_STEPS / cells + 1)
end
for lane = 1, NUM_LANES do
Steps[lane], Paint[lane] = {}, {}
end
for track = 1, NUM_TRACKS do
local y = ROW_Y + (track - 1) * ROW_PITCH
local buttonY = y + math.floor((ROW_H - 20) / 2) -- the row is taller than its own controls
local menuY = y + math.floor((ROW_H - 22) / 2)
-- A Label's first argument is both its text and its parameter id, so track names have to stay
-- unique for renamed tracks to come back with the preset.
Name[track] = Label{TRACK_NAMES[track], bounds = {MARGIN, y, 60, ROW_H}, editable = true,
textColour = TRACK_COLOURS[track], textColourWhenEditing = TRACK_COLOURS[track],
backgroundColour = "#00000000", backgroundColourWhenEditing = "#00000000",
tooltip = "Rename this track"}
-- 22 px, not 18: a one-letter caption still needs room for the widest letter plus the button's
-- own padding, or it gets replaced by an ellipsis.
Mute[track] = OnOffButton{"Mute_" .. track, false, bounds = {73, buttonY, 22, 20},
displayName = "M", tooltip = "Mute " .. TRACK_NAMES[track]}
Solo[track] = OnOffButton{"Solo_" .. track, false, bounds = {97, buttonY, 22, 20},
displayName = "S", tooltip = "Solo " .. TRACK_NAMES[track]}
Note[track] = Menu{"Note_" .. track, NOTE_NAMES, DEFAULT_NOTES[track] + 1,
bounds = {122, menuY, 56, 22}, showLabel = false, textColour = TRACK_COLOURS[track],
tooltip = "MIDI note played by " .. TRACK_NAMES[track]}
for lane = 1, NUM_LANES do
-- Storage grid: the pattern, always at full resolution. This is what plays and what
-- the host saves. It is never clicked, because the overlay below covers it.
Steps[lane][track] = Table{stepId(lane, track), NUM_STEPS, 0,
LANE_MIN[lane], LANE_MAX[lane], true,
bounds = {GRID_X, y, GRID_W, ROW_H}, fillStyle = "solid", drawInnerEdge = false,
sliderColour = TRACK_COLOURS[track], backgroundColour = "#00000000",
tooltip = TRACK_NAMES[track] .. " " .. LANE_NAMES[lane]}
-- Paint overlay: created after the storage grid so it sits on top and takes the mouse.
-- Its length is the current write resolution, so one cell here spans several storage
-- steps, and it holds no pattern data (hence persistent = false).
--
-- alpha = 0 hides it whole, which is the only reliable way to keep it out of sight: a
-- fully transparent sliderColour comes out as opaque black, and clearing the cell from
-- inside its own `changed` callback fights the drag the widget is in the middle of and
-- makes the row flicker. Hidden outright it draws nothing at any point, in or out of a
-- drag -- which is also why the write grid needs its own widgets.
Paint[lane][track] = Table{"Paint_" .. lane .. "_" .. track,
RESOLUTION_CELLS[DEFAULT_RESOLUTION] * NUM_BARS, 0,
LANE_MIN[lane], LANE_MAX[lane], false,
bounds = {GRID_X, y, GRID_W, ROW_H}, fillStyle = "solid", drawInnerEdge = false,
alpha = 0, persistent = false,
tooltip = "Draw " .. TRACK_NAMES[track] .. " " .. LANE_NAMES[lane] .. " here"}
Paint[lane][track].changed = function(self, cell)
if suspendPaint then return end -- re-parking the overlay is not the user drawing on it
-- The overlay is a float table (it has to hold PAINT_IDLE), the pattern is integer.
writeCell(lane, track, cell, self.length, math.floor(self:getValue(cell) + 0.5))
invalidateMidi()
end
end
end
-- Park one overlay row back on its resting value. Needed after anything that changes the pattern
-- without going through the mouse: a cell still holding the value it was last dragged to could
-- not be dragged to that same value again, so a step a tool has just written would refuse to be
-- erased by hand.
local function parkPaintCells(lane, track)
local cells = paintCells()
suspendPaint = true
Paint[lane][track].length = cells
for cell = 1, cells do
Paint[lane][track]:setValue(cell, PAINT_IDLE, false)
end
suspendPaint = false
end
local function refreshWriteGrid()
local span = NUM_STEPS / paintCells()
for step = 1, NUM_STEPS do
writeGridBars[step].visible = (step - 1) % span == 0
end
for lane = 1, NUM_LANES do
for track = 1, NUM_TRACKS do
parkPaintCells(lane, track)
end
end
end
--------------------------------------------------------------------------------
-- Pattern tools
--------------------------------------------------------------------------------
-- The first entry is the blank resting state this menu returns to after every command. A blank
-- label is what lets the widget stay narrow enough to fit the row -- any real word would be cut
-- down to an ellipsis at this width, and the tooltip carries the meaning anyway. It has to be a
-- space rather than an empty string: a menu entry cannot be empty.
local TOOL_ITEMS = {" ", "Rotate left", "Rotate right", "Reverse", "Clear"}
local EUCLID_FIRST = #TOOL_ITEMS + 1
for hits = 1, 16 do
TOOL_ITEMS[#TOOL_ITEMS + 1] = "Euclid/" .. hits .. " hits"
end
-- Tools read the whole row at the write resolution and write it back through writeCell(), so
-- anything drawn finer than the current write grid is lost -- exactly as if the row had been
-- redrawn by hand. Pick the write resolution before reaching for a tool.
local function remapCells(lane, track, cells, mapping)
local values = {}
for cell = 1, cells do
values[cell] = readCell(lane, track, cell, cells)
end
for cell = 1, cells do
writeCell(lane, track, mapping(cell, cells), cells, values[cell])
end
end
-- The one exception to "every edit goes through writeCell()": a wipe writes zero, which is a
-- valid cell content at every resolution, so it can hit the fine grid directly -- and it should,
-- to take steps drawn finer than the current write grid with it.
local function clearLane(lane, track)
for step = 1, NUM_STEPS do
Steps[lane][track]:setValue(step, 0)
end
end
-- A Euclidean rhythm spreads `hits` onsets as evenly as possible over `cells`. Bjorklund's
-- algorithm reduces to this test, which is why it fits on one line: cell i carries an onset when
-- (i * hits) mod cells lands below hits.
local function euclid(track, hits, cells)
for cell = 1, cells do
local onset = (cell - 1) * hits % cells < hits
writeCell(1, track, cell, cells, onset and EUCLID_VELOCITY or 0)
end
end
for track = 1, NUM_TRACKS do
Tools[track] = Menu{"Tools_" .. track, TOOL_ITEMS, 1,
bounds = {182, ROW_Y + (track - 1) * ROW_PITCH + math.floor((ROW_H - 22) / 2), 44, 22},
showLabel = false,
persistent = false, hierarchical = true,
tooltip = "Rotate, reverse, clear or fill this track with a Euclidean rhythm"}
Tools[track].changed = function(self)
local choice = self.value
self:setValue(1, false) -- a command list, not a state: notify = false, so no recursion
local lane, cells = Lane.value, paintCells()
if choice == 2 then -- rotate left
remapCells(lane, track, cells, function(cell, count) return (cell - 2) % count + 1 end)
elseif choice == 3 then -- rotate right
remapCells(lane, track, cells, function(cell, count) return cell % count + 1 end)
elseif choice == 4 then -- reverse
remapCells(lane, track, cells, function(cell, count) return count - cell + 1 end)
elseif choice == 5 then -- clear
clearLane(lane, track)
elseif choice >= EUCLID_FIRST then
lane = 1 -- a Euclidean rhythm is a trigger pattern: always the velocity lane
euclid(track, choice - EUCLID_FIRST + 1, cells)
end
parkPaintCells(lane, track) -- so the mouse can still erase what the tool just wrote
invalidateMidi()
end
end
-- Editing only ever flows from the overlay into the pattern, never back. So a preset loads with
-- its pattern showing and a blank overlay, and drawing at 1/4 over a 1/64T fill only rewrites the
-- cell being touched.
local function refreshLaneVisibility()
for lane = 1, NUM_LANES do
local shown = lane == Lane.value
for track = 1, NUM_TRACKS do
Steps[lane][track].visible = shown
Paint[lane][track].visible = shown
end
end
end
--------------------------------------------------------------------------------
-- Pattern files
--------------------------------------------------------------------------------
-- Preset state already survives on its own: every pattern widget is natively persistent, so the
-- host saves it with the preset and there is no onSave/onLoad here. Files are a different need --
-- patterns you can ship, swap and share -- and that is all this pair does.
local function collectPattern()
local data = {}
for lane = 1, NUM_LANES do
for track = 1, NUM_TRACKS do
local steps = {}
for step = 1, NUM_STEPS do
steps[step] = Steps[lane][track]:getValue(step)
end
data[stepId(lane, track)] = steps
end
end
for track = 1, NUM_TRACKS do
data["Note_" .. track] = Note[track].value
end
return data
end
local function applyPattern(data)
for lane = 1, NUM_LANES do
for track = 1, NUM_TRACKS do
local steps = data[stepId(lane, track)]
if steps then
for step = 1, NUM_STEPS do
Steps[lane][track]:setValue(step, steps[step] or 0)
end
end
end
end
for track = 1, NUM_TRACKS do
local note = data["Note_" .. track]
if note then Note[track]:setValue(note, false) end
end
refreshWriteGrid() -- the loaded pattern did not come from the overlays: park them all
invalidateMidi()
end
--------------------------------------------------------------------------------
-- MIDI file export
--------------------------------------------------------------------------------
local midiExportCounter = 0
-- Swing delays each step by a sine taken over the swing division. Summed over a whole division
-- that correction is exactly zero, so the pattern cannot drift out of tempo whatever the
-- division. Playback and the MIDI export share this one implementation of the rule, so the two
-- cannot drift apart. `phase` is the step's position within its division, in 0..1.
local function swungStepBeats(phase, amount)
return STEP_BEATS * (1 + amount * math.sin(phase * 2 * math.pi))
end
-- Only the velocity lane survives the trip: pan and tune are voice parameters, not note data.
-- Mute and solo are not applied either -- the file carries the pattern and the mix stays live,
-- which is how the factory script exports as well.
local function exportMidi()
createMidiFile(1, NUM_TRACKS * NUM_STEPS * 2, function(task)
local mf = task.midi
local ppq = mf.division
local swingSpan = SWING_STEPS[SwingDivision.value]
local gateTicks = ppq * 0.25 * Gate.value
for track = 1, NUM_TRACKS do
local note = Note[track].value - 1
local tick = 0
for step = 1, NUM_STEPS do
local velocity = Steps[1][track]:getValue(step)
if velocity > 0 then
local onset = math.floor(tick + 0.5)
local release = onset + math.floor(gateTicks + 0.5)
mf:insertEvent(1, uvi.MidiEvent(onset, 0, Event.NoteOn, note, velocity))
mf:insertEvent(1, uvi.MidiEvent(release, 0, Event.NoteOff, note, velocity))
end
local phase = (step - 1) % swingSpan / swingSpan
tick = tick + ppq * swungStepBeats(phase, Swing.value)
end
end
-- A fresh name every time: a host that already imported this path may otherwise hand back
-- its cached copy instead of re-reading the file.
midiExportCounter = midiExportCounter + 1
local path = getLocation("Temp") .. "/DrumSequencerKISS_" .. midiExportCounter .. ".mid"
saveMidi(mf, path, function()
MidiDrag.dragAndDropFilepath = path
MidiDrag.visible = true
end)
end)
end
--------------------------------------------------------------------------------
-- Playback
--------------------------------------------------------------------------------
local playGeneration = 0
local playheadCell
local function clearPlayhead()
for cell = 1, PLAYHEAD_CELLS do
Playhead:setValue(cell, 0)
end
playheadCell = nil
end
local function showPlayhead(step)
local cell = math.floor((step - 1) / PLAYHEAD_SPAN) + 1
if cell ~= playheadCell then
if playheadCell then Playhead:setValue(playheadCell, 0) end
Playhead:setValue(cell, 1)
playheadCell = cell
end
end
local function anySolo()
for track = 1, NUM_TRACKS do
if Solo[track].value then return true end
end
return false
end
local function playStep(step, gateMs)
local soloing = anySolo()
for track = 1, NUM_TRACKS do
local audible = Solo[track].value or not (soloing or Mute[track].value)
local velocity = Steps[1][track]:getValue(step)
if audible and velocity > 0 then
-- Continuous lanes fill their whole cell, so the pan and tune under this step are
-- the ones the user drew, whatever resolution they drew them at.
local pan = Steps[2][track]:getValue(step) / 100
local tune = Steps[3][track]:getValue(step)
playNote(Note[track].value - 1, velocity, gateMs, nil, nil, nil, 1, pan, tune)
end
end
end
local function playLoop(generation)
local step = 1
local swingSpan = SWING_STEPS[SwingDivision.value]
local swingAmount = Swing.value
while generation == playGeneration do
playStep(step, beat2ms(0.25 * Gate.value))
showPlayhead(step)
-- Both swing controls are re-read on division boundaries only, so moving them mid-division
-- cannot unbalance the division in progress.
local phase = (step - 1) % swingSpan / swingSpan
if phase == 0 then
swingSpan = SWING_STEPS[SwingDivision.value]
swingAmount = Swing.value
end
-- waitBeat is tempo-relative, so the sequence follows the host without any tempo tracking.
waitBeat(swungStepBeats(phase, swingAmount))
step = step % NUM_STEPS + 1
end
end
local function startPlaying()
playGeneration = playGeneration + 1 -- also cancels a loop that is already running
Play:setValue(true, false) -- notify = false, so this cannot recurse through Play.changed
spawn(playLoop, playGeneration)
end
local function stopPlaying()
playGeneration = playGeneration + 1 -- the running loop exits at its next wake-up
Play:setValue(false, false)
clearPlayhead()
end
local function refreshTriggerKey()
for note = 0, 127 do
end
setKeyColour(TriggerNote.value - 1, TRIGGER_KEY_COLOUR)
end
--------------------------------------------------------------------------------
-- Callbacks
--------------------------------------------------------------------------------
Play.changed = function(self)
if self.value then startPlaying() else stopPlaying() end
end
Resolution.changed = refreshWriteGrid
Lane.changed = refreshLaneVisibility
TriggerNote.changed = function()
refreshTriggerKey()
stopPlaying() -- the key that started the sequence is not the trigger key any more
end
-- browseForFile is asynchronous: it returns immediately and the callback runs once the user has
-- picked a path, with task.success telling you whether they went through with it.
SavePattern.changed = function()
browseForFile("save", "Save pattern", "", "*." .. PATTERN_EXT, function(task)
if task.success then saveData(collectPattern(), task.result) end
end)
end
LoadPattern.changed = function()
browseForFile("open", "Load pattern", "", "*." .. PATTERN_EXT, function(task)
if task.success then loadData(task.result, applyPattern) end
end)
end
ExportMidi.changed = exportMidi
-- The MIDI file bakes in more than the pattern: track notes, swing and gate land in it too.
for track = 1, NUM_TRACKS do
Note[track].changed = invalidateMidi
end
Swing.changed = invalidateMidi
Gate.changed = invalidateMidi
SwingDivision.changed = invalidateMidi
function onNote(e)
if e.note == TriggerNote.value - 1 then
startPlaying()
else
-- playNote() rather than postEvent(): if the trigger key is moved while a key is held, the
-- matching onRelease would stop forwarding its note-off and the voice would hang. A
-- duration of -1 releases the voice with the incoming key instead.
playNote(e.note, e.velocity, -1, e.layer, e.channel, e.input, e.vol, e.pan, e.tune, e.slice)
end
end
function onRelease(e)
if e.note == TriggerNote.value - 1 then
stopPlaying()
end
end
refreshWriteGrid()
refreshLaneVisibility()
refreshTriggerKey()
invalidateMidi()
setSize(720, 480)
Event types.
Definition Engine.cpp:723
text label widget.
Definition ui.cpp:1083
Label(string name)
creates a label widget on the user interface.
A Midi Event.
Definition Engine.cpp:741
function changed
callback function used by child widgets to be notified of changes
Definition ui.cpp:875
function createMidiFile(maxNumTracks, maxNumEvents, callback)
create a midi file asynchronously
Definition api.lua:275
function saveData(data, path, callback)
save data to file.
Definition api.lua:239
function saveMidi(midifile, path, callback)
save a midi file asynchronously
Definition api.lua:296
function loadData(path, callback)
load data from file.
Definition api.lua:145
function browseForFile(mode, title, initialFileOrDirectory, filePatterns, callback)
launch a file chooser to select a file to open or save
Definition api.lua:32
string getLocation(string location)
table onSave()
callback that is called when saving the ScriptProcessor state.
void onLoad(table data)
callback that is called when restoring a ScriptProcessor state if custom data has been saved.
ScriptProcessor & this
Reference to the current ScriptProcessor instance.
Definition Engine.cpp:552
function run(fun,...)
launch a function in a separate parallel execution thread.
Definition api.lua:1227
void setBackgroundColour(string colour)
set the script background colour.
function resetKeyColour(note)
customize the keyboard colours.
Definition ui.lua:61

⬇ Download DrumSequencer.lua


StepSequencer

Beat-Synced Step Sequencer with Position Display

A simple 8-step pitch sequencer that plays in sync with the host tempo. Uses a Table widget for step editing and a second Table as a visual position indicator. Demonstrates waitBeat, Table widget, and the sequencer pattern found in many factory presets.

Demonstrates: Table, waitBeat, playNote, beat2ms, spawn, Panel

local numSteps = 8
local panel = Panel("Sequencer")
panel.backgroundColour = "3f000000"
local steps = panel:Table("pitch", numSteps, 0, -12, 12, true)
steps.width = 500
steps.height = 120
-- position indicator (read-only)
local position = panel:Table("pos", numSteps, 0, 0, 1, true)
position.enabled = false
position.persistent = false
position.width = steps.width
position.height = 10
local gate = panel:Knob{"Gate", 0.8, 0, 1, unit = Unit.PercentNormalized}
local resolution = panel:Menu{"Resolution", {"1/8", "1/16", "1/32"}, selected = 2}
local resValues = {0.5, 0.25, 0.125} -- in beats
local seqId = 0
function onNote(e)
seqId = seqId + 1
local myId = seqId
local step = 0
while myId == seqId do
local res = resValues[resolution.value]
local note = e.note + steps:getValue(step + 1)
playNote(note, e.velocity, beat2ms(gate.value * res))
-- update position display
position:setValue((step - 1 + numSteps) % numSteps + 1, 0)
position:setValue(step + 1, 1)
step = (step + 1) % numSteps
waitBeat(res)
end
end
function onRelease(e)
seqId = seqId + 1 -- stop the running loop
for i = 1, numSteps do position:setValue(i, 0) end
end
setSize(650, 200)

⬇ Download StepSequencer.lua


MIDI Tools

CCFilter

Block a Specific MIDI CC

Blocks a user-selected CC from passing through. All other events are forwarded unchanged. Demonstrates selective event filtering with onController.

Demonstrates: onController, postEvent, event filtering, Knob

local blocked = Knob{"BlockedCC", 64, 0, 127, true, displayName = "Blocked CC"} -- integer knob
function onController(e)
if e.controller ~= blocked.value then
end
end
void onController(table e)
event callback that will receive all incoming control-change events when defined.

⬇ Download CCFilter.lua


CCRedirect

Remap a MIDI CC Number to Another

Remaps incoming CC messages from one controller number to another. Other events pass through unchanged. Demonstrates event property mutation before forwarding.

Demonstrates: onController, postEvent, event mutation, Menu

local source = Menu{"SourceCC", {"1 (Mod Wheel)", "2 (Breath)", "7 (Volume)", "11 (Expression)"}, displayName = "Source CC"}
local target = Menu{"TargetCC", {"1 (Mod Wheel)", "2 (Breath)", "7 (Volume)", "11 (Expression)"}, selected = 4, displayName = "Target CC"}
local ccMap = {1, 2, 7, 11}
function onController(e)
if e.controller == ccMap[source.value] then
e.controller = ccMap[target.value] -- mutate the event
end
postEvent(e) -- forward (modified or not)
end

⬇ Download CCRedirect.lua


CCSmooth

Smooth Incoming CC Messages Over Time

Applies exponential smoothing to a MIDI CC, producing gradual transitions instead of abrupt jumps. Demonstrates spawning a background task from onController and generating CC output.

Demonstrates: onController, controlChange, spawn, wait, Knob, Mapper

local smoothTime = Knob{"Smooth", 0.2, 0, 2, mapper = Mapper.Cubic, unit = Unit.Seconds}
local targetCC = Knob{"CC", 1, 0, 127, true} -- which CC to smooth
local currentValue = 0
local targetValue = 0
local running = false
function smoothCC()
running = true
while math.abs(currentValue - targetValue) > 0.5 do
currentValue = currentValue + (targetValue - currentValue) * 0.15
controlChange(targetCC.value, math.floor(currentValue + 0.5))
wait(5)
end
currentValue = targetValue
controlChange(targetCC.value, math.floor(currentValue))
running = false
end
function onController(e)
if e.controller == targetCC.value then
targetValue = e.value
if not running then
spawn(smoothCC)
end
else
postEvent(e) -- forward other CCs unchanged
end
end
@ Cubic
Cubic mapper:
Definition ui.cpp:669
@ Seconds
display s symbol.
Definition ui.cpp:710
function controlChange(cc, val, ch, inp)
sends a ControlChange event.
Definition api.lua:1067

⬇ Download CCSmooth.lua


MidiLearn

MIDI Learn for Note Assignment

Demonstrates the MIDI learn pattern: press a button to enter learn mode, then play a note to assign it. The learned note is displayed and used to transpose incoming notes.

Demonstrates: Button, Label, onNote, setKeyColour, resetKeyColour, MIDI learn pattern

local learnedNote = 60
local learning = false
local learnBtn = Button{"Learn"}
local status = Label{"status"}
status.text = "C4"
-- assigned after the status label declaration so the closure captures the local
learnBtn.changed = function(self)
learning = true
status.text = "Play a note..."
end
function onNote(e)
if learning then
-- assign the played note
resetKeyColour(learnedNote)
learnedNote = e.note
setKeyColour(learnedNote, "00FF00") -- green
status.text = string.format("Note: %d", learnedNote)
learning = false
else
-- transpose relative to learned note
local offset = e.note - 60
playNote(learnedNote + offset, e.velocity, -1)
end
end
function onRelease(e)
-- eat releases: playNote handles them via duration -1
end
setKeyColour(learnedNote, "00FF00")
string text
text to display on screen
Definition ui.cpp:1091

⬇ Download MidiLearn.lua


Asset Loading

IRLoader

Hierarchical IR menu with userReady guard

A hierarchical Menu (path-based entries grouped by "/") that loads an impulse response into a SampledReverb insert. The menu's selection is persisted with the preset, so the user's chosen IR survives a save / load round-trip. Why this example shows the userReady flag: When a preset loads, the engine restores every persistent widget to its saved value. For widgets whose .changed callback performs an expensive side effect (here loadImpulse, which hits the disk), we must not let that callback fire during the restore — the IR is already in the patch. The standard guard:

  • declare a flag userReady = false at script load,
  • flip it to true in onInit (which fires AFTER preset state has been fully restored),
  • have any side-effecting .changed early-return when the flag is false. With the guard in place, only real user clicks trigger loadImpulse. The same pattern protects any expensive .changed side effect: loadSample, network calls, rebuilding a big lookup, etc. See Chorder for the alternative pattern when the menu writes into other widgets and you want the menu non-persistent.

Demonstrates: onInit, Menu, Label, loadImpulse, hierarchical menu, userReady guard

-- Flip to true in onInit, after preset state has been fully restored.
-- Every .changed that performs a destructive side effect must check this.
local userReady = false
local reverb = Program.inserts[1]
-- Hierarchical menu: "/" creates sub-menu levels.
local irList = {
"Halls/Large Hall",
"Halls/Medium Hall",
"Halls/Small Hall",
"Plates/Bright Plate",
"Plates/Dark Plate",
"Rooms/Studio A",
"Rooms/Studio B",
"Rooms/Living Room",
"Springs/Short Spring",
"Springs/Long Spring",
}
local irMenu = Menu{"IR", irList,
hierarchical = true,
backgroundColour = "333333",
textColour = "white",
}
local status = Label{"status"}
status.text = "No IR loaded"
status.textColour = "aaaaaa"
irMenu.changed = function(self)
if not userReady then return end -- preset load: do not re-hit disk
local irName = self.selectedText
local irPath = "impulses/" .. irName .. ".wav"
status.text = "Loading..."
status.textColour = "aaaaaa"
loadImpulse(reverb, irPath, function(task)
if task.success then
status.text = irName
status.textColour = "00FF88"
else
status.text = "Failed: " .. irName
status.textColour = "FF4444"
end
end)
end
function onInit()
userReady = true
-- We do NOT call irMenu:changed() here. The patch already contains
-- the matching IR; re-running loadImpulse would be a redundant disk
-- hit on every preset load.
end
table inserts
all InsertEffect for this node
Definition Engine.cpp:262
function loadImpulse(reverb, path, callback)
load and impulse response inside the reverb.
Definition api.lua:327

⬇ Download IRLoader.lua


SampleDropper

Load Samples via Drag and Drop

Creates a drag-and-drop zone that loads dropped audio files into the first oscillator of the current layer. Demonstrates DnDArea and async loadSample with visual feedback.

Demonstrates: DnDArea, loadSample, Program.layers, Label, FileFormat

local osc = Program.layers[1].keygroups[1].oscillators[1]
local status = Label{"status"}
status.text = "Drop a sample here"
status.align = "centred"
status.textColour = "white"
local dnd = DnDArea("drop")
dnd.acceptedFileFormat = FileFormat.Audio
dnd.bounds = {5, 30, 350, 60}
dnd.backgroundColour = "3fFFFFFF"
dnd.fileDropped = function(self)
status.text = "Loading..."
loadSample(osc, self.filepath, function(task)
if task.success then
status.text = osc.sampleInfo.name
else
status.text = "Load failed"
end
end)
end
setSize(360, 100)
DnDArea widget.
Definition ui.cpp:1045
Predefined format file types.
Definition ui.cpp:792
function loadSample(oscillator, path, callback)
load a sample inside the oscillator
Definition api.lua:67

⬇ Download SampleDropper.lua


UI Helpers

FX Controls

Bind a Program insert effect to the script UI

Builds a small control panel for the first insert effect on the Program, here a "Drive". The Param* widgets bind straight to the effect's parameters: each control inherits its range, default and unit from the parameter and stays in sync with host automation, modulation and preset changes - no changed callback is needed to drive the effect. ParameterValue adds a non-visual binding, used here to read a parameter's live value from script. The parameter names ("Bypass", "DriveAmount", "Mode") are the internal names of the Drive effect; point Program.inserts[1] at that effect, or swap the names for those of your own FX.

Demonstrates: ParamKnob, ParamOnOffButton, ParamMenu, ParameterValue, Panel

-- the effect we want to control: first insert on the Program
local fx = Program.inserts[1]
local panel = Panel{"drive"}
panel.bounds = {20, 20, 420, 150}
panel.backgroundColour = "2a2a2a"
-- No ranges are given: they are inherited from the bound parameter.
-- Bounds are relative to the panel.
local bypass = panel:ParamOnOffButton(fx, "Bypass") -- boolean parameter
bypass.bounds = {25, 58, 110, 36}
local amount = panel:ParamKnob(fx, "DriveAmount") -- 0..1, shown as %
amount.bounds = {180, 25, 100, 100}
local mode = panel:ParamMenu(fx, "Mode") -- enumerated parameter (entries from the param)
mode.bounds = {290, 54, 110, 44}
--------------------------------------------------------------------------------
-- Non-visual (logical) binding: read the live drive amount from script without
-- placing a control for it. ParameterValue draws nothing; it just exposes the
-- parameter through its .value.
--------------------------------------------------------------------------------
local driveValue = ParameterValue(fx, "DriveAmount")
bypass.changed = function(self)
local state = self.value and "bypassed" or "active"
print(string.format("Drive %s (amount = %d%%)", state, math.floor(driveValue.value * 100 + 0.5)))
end
setSize(460, 190)
A knob bound to an existing Element parameter.
Definition ui.cpp:1591
A dropdown menu bound to an existing enumerated Element parameter.
Definition ui.cpp:1696
An on/off button bound to an existing boolean Element parameter (e.g.
Definition ui.cpp:1664
A non-visual (logical) binding to an existing Element parameter.
Definition ui.cpp:1764
Value value
the bound parameter's value (read and write)
Definition ui.cpp:1778
table bounds
widget bounding rect {x,y,width,height}
Definition ui.cpp:865

⬇ Download FXControls.lua ⬇ Download Bundled Drive patch (load in Falcon)


PanelSwitcher

Tab-Based Panel Switching with Main/FX/Seq Views

Demonstrates the standard pattern for building a tabbed interface. Three OnOffButtons act as tab selectors, toggling visibility of three Panel containers. Each panel holds its own set of widgets. This pattern is used extensively in Falcon factory presets.

Demonstrates: OnOffButton, Panel, Knob, Slider, Table, Mapper, Unit

-- Tab buttons
local tabNames = {"Main", "FX", "Seq"}
local tabButtons = {}
local tabPanels = {}
local contentY = 30
local contentH = 100
for i = 1, #tabNames do
tabButtons[i] = OnOffButton{tabNames[i], i == 1,
bounds = {(i - 1) * 80, 0, 78, 25},
backgroundColourOff = "333333",
backgroundColourOn = "FF8800",
textColourOff = "aaaaaa",
textColourOn = "ffffff",
persistent = false
}
tabPanels[i] = Panel{bounds = {0, contentY, 500, contentH},
backgroundColour = "2f000000"
}
end
-- Main panel: basic voice controls
tabPanels[1]:Knob{"Volume", 0.8, 0, 1, unit = Unit.LinearGain}
tabPanels[1]:Knob{"Pan", 0, -1, 1, unit = Unit.Pan}
tabPanels[1]:Knob{"Tune", 0, -12, 12, unit = Unit.SemiTones}
-- FX panel: filter controls
tabPanels[2]:Knob{"Cutoff", 20000, 20, 20000, mapper = Mapper.Exponential, unit = Unit.Hertz}
tabPanels[2]:Knob{"Reso", 0, 0, 1, unit = Unit.PercentNormalized}
tabPanels[2]:Slider{"Mix", 1, 0, 1, unit = Unit.PercentNormalized}
-- Seq panel: step sequencer
tabPanels[3]:Table("Steps", 8, 0, -12, 12, true)
tabPanels[3]:Knob{"Rate", 0.25, 0.0625, 1}
-- Tab switching: deselect all others, show only the active panel
for i = 1, #tabButtons do
tabButtons[i].changed = function()
for j = 1, #tabButtons do
tabButtons[j]:setValue(j == i, false) -- deselect others without triggering callback
tabPanels[j].visible = (j == i) -- show only matching panel
end
end
end
tabButtons[1]:changed() -- show Main tab by default
setSize(500, contentY + contentH + 5)
Horizontal or vertical slider widget.
Definition ui.cpp:1457
@ Pan
display -1;1 pan value type
Definition ui.cpp:734
@ SemiTones
display semitones symbol
Definition ui.cpp:742
@ LinearGain
display dB symbol but with normalized gain
Definition ui.cpp:726

⬇ Download PanelSwitcher.lua


TemporaryDisplay

Flash Knob Values on Labels with Auto-Revert

Shows a reusable pattern for temporarily displaying a knob's value on a label, then reverting to the default name after a delay. Uses spawn + wait since widget callbacks are not coroutines and cannot call wait() directly. A counter ensures only the last update reverts, handling rapid knob turns.

Demonstrates: Label, Knob, Panel, Mapper, Unit, spawn, wait

--------------------------------------------------------------------------------
-- Reusable helper: flash a value on a label, revert after delay
--------------------------------------------------------------------------------
local flashCounters = {}
function flashLabel(label, text, defaultText, ms)
label.text = text
flashCounters[label] = (flashCounters[label] or 0) + 1
local myId = flashCounters[label]
spawn(function()
wait(ms or 1000)
if flashCounters[label] == myId then
label.text = defaultText
end
end)
end
--------------------------------------------------------------------------------
-- Layout
--------------------------------------------------------------------------------
local panel = Panel{"controls"}
panel.bounds = {10, 10, 340, 90}
panel.backgroundColour = "2a2a2a"
local margin = 10
local knobSize = 60
local labelH = 16
local function makeKnobWithLabel(parent, name, default, min, max, x, opts)
local label = parent:Label{name,
bounds = {x, margin + knobSize + 2, knobSize, labelH},
align = "centred", fontSize = 11,
textColour = "888888", backgroundColour = "#00000000"
}
local knob = parent:Knob{name .. "_k", default, min, max,
bounds = {x, margin, knobSize, knobSize},
showLabel = false, showValue = false, showPopupDisplay = false,
fillColour = opts.colour or "555555",
outlineColour = opts.colour or "888888"
}
if opts.mapper then knob.mapper = opts.mapper end
if opts.unit then knob.unit = opts.unit end
return knob, label, name
end
local volKnob, volLabel, volName = makeKnobWithLabel(panel, "VOL", 0.8, 0, 1, margin,
{mapper = Mapper.Cubic, unit = Unit.LinearGain, colour = "FF8800"})
local cutKnob, cutLabel, cutName = makeKnobWithLabel(panel, "CUTOFF", 20000, 20, 20000, margin + 70 + margin,
{mapper = Mapper.Exponential, unit = Unit.Hertz, colour = "FFCC00"})
local resKnob, resLabel, resName = makeKnobWithLabel(panel, "RES", 0, 0, 1, margin + 2 * (70 + margin),
{unit = Unit.PercentNormalized, colour = "FFCC00"})
--------------------------------------------------------------------------------
-- Wire up temporary display
--------------------------------------------------------------------------------
volKnob.changed = function(self)
local dB = self.value > 0 and string.format("%.1f dB", 20 * math.log10(self.value)) or "-inf"
flashLabel(volLabel, dB, volName, 800)
end
cutKnob.changed = function(self)
local text = self.value < 1000
and string.format("%.0f Hz", self.value)
or string.format("%.1f kHz", self.value / 1000)
flashLabel(cutLabel, text, cutName, 800)
end
resKnob.changed = function(self)
flashLabel(resLabel, string.format("%.0f%%", self.value * 100), resName, 800)
end
setSize(360, 110)
Mapper::Type mapper
Mapper type, default is Mapper.Linear.
Definition ui.cpp:884

⬇ Download TemporaryDisplay.lua


See Also