Skip to content

RT Scripting: The Sequencer Engine (Pass 1)

The sequencer is implemented as an RT script — a Lua processor that sits in the event chain and both responds to incoming MIDI (to start and stop playback) and drives its own timing via onProcessBlock to fire steps at the right moments. This pass covers the core loop: starting, stopping, stepping through stepData, and publishing the playhead position to the UI. Per-step modulation (velocity, detune, filter, wavetable position) is added in Pass 2.

Right-click patches -> main -> scripts in the file tree and choose Create new Script. Name it step_sequencer.

Creating the step_sequencer script

Open the new script in the editor and paste the following code. IMPORTANT: make sure to delete everything that’s already in the editor first! Select everything in the editor with ctrl+A / command+A, then paste the code over it:

-- Script: "step_sequencer"
--
-- BEGIN_INFO_BLOCK
scriptInfo = {
scope = 'event-processor',
parameters = {}
}
-- END_INFO_BLOCK
-- list of referenced UIDs (replace with your own from the editor):
local mainZoneMapId = "<copy from your editor>"
local stepData
stepData = sys_subscribe_to_parameter("stepData", function(v)
stepData = v
end)
-- key within octave: determined by the trigger note:
local keyWithinOctave
-- time until the next step is due:
local timeUntilNextStep
-- currently played step (playhead). NOTE: 1-based indices throughout;
-- stepIdx == 0 (or < 1) means "not playing":
local stepIdx = 0
function onProcessEvents(blockLen, events)
for i, event in ipairs(events) do
if event.type == sys_event_types.noteOn then
-- start playback at step 1, at the block offset of the incoming note:
timeUntilNextStep = event.offset
stepIdx = 1
keyWithinOctave = event.note % 12
end
if event.type == sys_event_types.noteOff then
stepIdx = 0
-- notify the UI that playback has stopped:
sys_set_parameter_value("stepPlaying", stepIdx)
end
end
end
function startNote(curStep, offset, length)
-- muted steps have a note value of -1:
if curStep.note < 0 then return end
local eventId = sys_create_note_event(
offset,
0,
curStep.note + keyWithinOctave,
100,
length)
sys_force_note_event_to_zone_map(eventId, mainZoneMapId)
end
function onProcessBlock(blockLen)
-- nothing to do if we're not playing:
if stepIdx < 1 then return end
-- is there a new step ready to play within the current block?
if timeUntilNextStep < blockLen then
-- publish the currently playing step (play-head) for the UI to display:
sys_set_parameter_value("stepPlaying", stepIdx)
local lengthOneSixteenth = sys_get_quarter_note_length() / 4
local curStep = stepData[stepIdx]
-- play a note at the calculated offset and a length of 1 eighth note (2 × 16th):
startNote(curStep, timeUntilNextStep, lengthOneSixteenth * 2)
-- calculate the remaining time until the next step is due:
timeUntilNextStep = timeUntilNextStep + lengthOneSixteenth
-- advance the step index; wrap back to 1 after step 16:
stepIdx = stepIdx + 1
if stepIdx > 16 then
stepIdx = 1
end
end
-- after the block is finished, decrease the time until next step by the block duration:
timeUntilNextStep = timeUntilNextStep - blockLen
end

By default, any incoming noteOn event triggers the zone map directly. Because the sequencer intercepts noteOn events to start its own playback, those raw key events must not also trigger notes — otherwise every keypress would play a note on top of the sequencer output.

Open the layer editor for main.flr. In the source processor, click Edit in the left (note-on) column and set main.zmap’s condition to Never. The zone map is now silent to direct key input; only the script can route notes into it.

Setting the main zone map's note-on condition to Never

Copy the Zone Map UID and Update the Script

Section titled “Copy the Zone Map UID and Update the Script”

The script routes every sequencer note into main.zmap via sys_force_note_event_to_zone_map, which requires the zone map’s UID. Open main.zmap in the zone map editor and copy its UID.

Copying the zone map UID from the zone map editor

In the script, find the UID section just below -- END_INFO_BLOCK and replace the placeholder string with the UID you just copied:

local mainZoneMapId = "1b1d60a5-8e90-46a4-a042-a90a745c62ab"

Note: the UID above is an example only — do not paste it verbatim. Each project generates its own unique IDs; always use the one you copied from your own zone map editor.

As the script grows in later passes, additional UIDs will be added to this section in the same way, keeping all external references in one place at the top of the file.

UIDs and named parameters. When right-clicking a parameter — a knob, fader, or similar value — the context menu offers two options: Copy Parameter UID and Assign name. You can assign a human-readable name and use that string everywhere a UID is expected. For zone maps and built-in modulation targets (such as the detune target used in Pass 2), naming is not supported and the menu only shows Copy Parameter UID. Processor nodes (script modulators, the master Amp used by the visualizer, etc.) do not show either naming option at all — their UID is always obtained by copying.

Named parameters are convenient but carry one risk: the names are arbitrary strings you define, and two parameters sharing the same name will collide, producing undefined behaviour. A simple naming scheme prevents this — the examples in this walkthrough use "processor/parameter" (e.g. "filter/cutoff", "send_levels/delay"). The zone map UID above is copied the traditional way because zone maps do not support naming; later passes will show both approaches.

Switch back to the main.flr layer editor. Drag a Script processor from the processor list and drop it before the source processor — it must sit first in the chain so it intercepts noteOn and noteOff events before the source sees them.

Script processor inserted before the source processor

Drag step_sequencer.script from the file tree and drop it into the script processor where it reads <drop a script here>.

Unfold the script console. It should report successfully compiled.

Script console showing "successfully compiled"

With that, the sequencer is live. Press any key on the keyboard and the script will start cycling through the 16 steps, playing notes from main.zmap at the current host tempo.

stepData = sys_subscribe_to_parameter("stepData", function(v)
stepData = v
end)

At load time, the script subscribes to the stepData global parameter and stores the current value in a local variable. The callback keeps that local in sync whenever the UI edits a step — the RT script always has the latest data without polling.

if event.type == sys_event_types.noteOn then
timeUntilNextStep = event.offset
stepIdx = 1
keyWithinOctave = event.note % 12
end
if event.type == sys_event_types.noteOff then
stepIdx = 0
sys_set_parameter_value("stepPlaying", stepIdx)
end

A noteOn starts the sequencer from step 1 (1-based throughout). The first step fires at event.offset — the exact moment the key was pressed — so there is no quantisation delay on the attack.

stepIdx == 0 is the “stopped” sentinel. Setting it on noteOff and immediately writing it to stepPlaying lets the UI clear its playhead the instant a key is released, rather than waiting for the next block.

keyWithinOctave captures the pitch class of the trigger key (note % 12), stripping the octave information. This value is added to each step’s stored note at playback time, so the sequence transposes to match whatever key the player holds — F transposes the sequence to F, regardless of octave.

local lengthOneSixteenth = sys_get_quarter_note_length() / 4

sys_get_quarter_note_length() returns one quarter note in seconds at the current host tempo. Dividing by 4 gives a sixteenth note, and the value stays in sync with the host automatically.

timeUntilNextStep is a running countdown. Each block, the script checks whether the next step falls within the current block (timeUntilNextStep < blockLen), fires it if so, then subtracts blockLen at the end regardless — keeping the countdown accurate across blocks of any size.

local curStep = stepData[stepIdx]
startNote(curStep, timeUntilNextStep, lengthOneSixteenth * 2)

stepData and stepIdx are both 1-based, so indexing is direct. startNote skips steps whose note is -1 (silence) and adds keyWithinOctave to the stored note for transposition. In this pass, all notes play at a fixed velocity of 100. Per-step velocity, detune, filter, and wavetable position are added in Pass 2.

sys_set_parameter_value("stepPlaying", stepIdx)

Written on every step so the UI can move its playhead indicator in real time. Also written to 0 on noteOff so the UI knows playback has stopped.

  • Per-step modulation: velocity, detune, filter, wavetable position (Pass 2)