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 Processing | Voice Effects | Performance & Articulation | Sequencing | MIDI Tools | Asset 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
⬇ 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)
local center = CenterPitch.value
local delta = e.note-center
local note = center - delta
if note>=0 and note<=127 then
end
end
-- 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
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
-- eat
event as release is done automatically by
playNote
end
for i=KSbaseNote, KSbaseNote+numLayers-1 do
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
displayName = "Latch",
tooltip = "When on, notes sustain after key-up"}
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
-- Re-pressing a latched note: clear it, do not forward.
if latchedVoice[e.note] then
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).
latchedAtPress[e.note] = LatchOn.value
if LatchOn.value then
latchedVoice[e.note] = vid
end
end
-- 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
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
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
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
if detune ~= 0 then
end
end
-- 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)
local shift = shiftKnob.value
local note = e.note + shift
local
id =
playNote(note, e.velocity, -1)
end
-- 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}
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 tune = -shift
wait(timeSpread*10) -- 10 ms jitter
end
end
-- 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
local duration = 0 -- in seconds
local step = 5 -- ms
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)
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
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
-- 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
local
id =
postEvent(e) -- duration is omitted
local phase = 0
local depth = Depth.value
local freq = Freq.value
local modulation = depth * math.sin(2 * math.pi * phase)
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
voices[e.note] = id
-- pan spread: note 0 = hard left, note 127 = hard right
local pan = (e.note / 127) * 2 - 1
end
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)
local sampleOffset = 50 -- ms
if #notes > 0 then
local fadetime = Fade.
value
fadeout(notes[#notes].
id, fadetime,
true)
table.insert(notes, e)
else
table.insert(notes, e)
end
end
for i,noteon in ipairs(notes) do
if noteon.note == e.note then
table.remove(notes, i)
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
end
break
end
end
end
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
while osc:getLoadingStatus() < 1.0 do
progress:setValue(osc:getLoadingStatus(), false)
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
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
currentDim1 = artMenu.selected - 1
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 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,
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
immediate = false
value = value + increment
t = t + period
end
end
_spawn(doglide)
end
if numNotes > 0 then
local fadetime = Fade.value
glide(lastid, 0, (e.note-lastnote), fadetime)
fadein(lastid, fadetime, true)
glide(lastid, (lastnote-e.note), 0, fadetime)
lastnote = e.note
else
lastnote = e.note
end
numNotes = numNotes + 1
end
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:
- 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.
- 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
------------------------------------------------------------------------
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]
velocity = tone.velocity,
duration =
beat2ms(beats) * Gate.value}
else
end
step = step + 1
end
end
------------------------------------------------------------------------
-- Callbacks
------------------------------------------------------------------------
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
end
end
-- 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
@ 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.
- 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.
- 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"
-- 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()
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"
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
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
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
if e.note == TriggerNote.value - 1 then
stopPlaying()
end
end
refreshWriteGrid()
refreshLaneVisibility()
refreshTriggerKey()
invalidateMidi()
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 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 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
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)
-- update position display
position:setValue((step - 1 + numSteps) % numSteps + 1, 0)
position:setValue(step + 1, 1)
step = (step + 1) % numSteps
end
end
seqId = seqId + 1 -- stop the running loop
for i = 1, numSteps do position:setValue(i, 0) end
end
⬇ 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
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}
if e.controller == ccMap[source.value] then
e.controller = ccMap[target.value] -- mutate the event
end
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 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
end
currentValue = targetValue
running = false
end
if e.controller == targetCC.value then
targetValue = e.value
if not running then
end
else
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"}
-- assigned after the status label declaration so the closure captures the local
learnBtn.changed = function(self)
learning = true
status.text = "Play a note..."
end
if learning then
-- assign the played note
learnedNote = e.note
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
-- eat releases:
playNote handles them via duration -1
end
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
-- 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"
if task.success then
status.text = irName
status.textColour = "00FF88"
else
status.text = "Failed: " .. irName
status.textColour = "FF4444"
end
end)
end
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 status =
Label{
"status"}
status.
text =
"Drop a sample here"
status.align = "centred"
status.textColour = "white"
dnd.bounds = {5, 30, 350, 60}
dnd.backgroundColour = "3fFFFFFF"
dnd.fileDropped = function(self)
status.text = "Loading..."
if task.success then
status.text = osc.sampleInfo.name
else
status.text = "Load failed"
end
end)
end
DnDArea widget.
Definition ui.cpp:1045
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 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.
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.
--------------------------------------------------------------------------------
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
A knob bound to an existing Element parameter.
Definition ui.cpp:1591
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
⬇ 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
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
-- FX panel: filter controls
-- 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
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]
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,
local cutKnob, cutLabel, cutName = makeKnobWithLabel(panel, "CUTOFF", 20000, 20, 20000, margin + 70 + margin,
local resKnob, resLabel, resName = makeKnobWithLabel(panel, "RES", 0, 0, 1, margin + 2 * (70 + margin),
--------------------------------------------------------------------------------
-- 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
⬇ Download TemporaryDisplay.lua
See Also