Your First Script
This chapter walks through writing a minimal RT script from scratch. By the end you will have a working transposer — a processor that shifts every incoming note up or down by a configurable interval before passing it on.
What We’re Building
Section titled “What We’re Building”A transposer is the simplest useful RT script:
- Receives every noteOn and noteOff event
- Adds a semitone offset to the pitch
- Forwards the modified note
- Passes all other events (controllers, timers) through unchanged
Setting Up a Script Processor
Section titled “Setting Up a Script Processor”The Script Info Header
Section titled “The Script Info Header”Every script must start with a script info block. For the transposer, we want one parameter — the semitone offset — so the player can adjust it from the UI without touching the script:
-- BEGIN_INFO_BLOCK
scriptInfo = { scope = 'event-processor', parameters = { Transpose = { type = 'interval', minValue = -24, maxValue = 24, defaultValue = 0, }, }}
-- END_INFO_BLOCKThe interval type represents a semitone offset — exactly what we need. Once
this script is loaded, a Transpose parameter will appear in the layer editor
and can be bound to a UI control, automated, or adjusted per-preset.
Understanding the NoteOn / NoteOff Relationship
Section titled “Understanding the NoteOn / NoteOff Relationship”Before writing any note-manipulation code, there is one key thing to understand:
a noteOn and its matching noteOff carry the same event.id, because they
refer to the same underlying engine note event.
This means you cannot simply replace both independently. The correct approach is:
- On noteOn: create a new note event (with infinite length, since the release is in the future), store a mapping from the incoming ID to the new event’s ID, and dispose the original.
- On noteOff: look up the mapping to find the note we created, call
sys_set_note_lengthto finalize its length at the release time, and dispose the original noteOff.
The mapping table must be script-level (outside the callback) so it persists between blocks.
Receiving and Transforming NoteOn
Section titled “Receiving and Transforming NoteOn”local active = {} -- maps incoming event id -> created event id
function onProcessEvents(blockLen, events) for i, event in ipairs(events) do local t = scriptInfo.parameters.Transpose.value
if event.type == sys_event_types.noteOn then -- Create a transposed note with infinite length local newId = sys_create_note_event( event.offset, event.channel, event.note + t, event.velocity, nil) -- Remember the mapping so we can finalize on noteOff active[event.id] = newId -- Remove the original from the queue sys_dispose_event(event.id) end endendHandling NoteOff
Section titled “Handling NoteOff” elseif event.type == sys_event_types.noteOff then local newId = active[event.id] if newId then -- Finalize the length of the created note at the release position sys_set_note_length(event.offset, newId) active[event.id] = nil end sys_dispose_event(event.id) endThe if newId guard handles edge cases such as a noteOff arriving without a
prior noteOn (e.g. on script load with keys already held).
Passing Other Events Through
Section titled “Passing Other Events Through”Forwarding is the default: any event not explicitly disposed passes through automatically. Controllers, timers, and anything else the script does not touch require no code at all.
The Complete Script
Section titled “The Complete Script”-- BEGIN_INFO_BLOCK
scriptInfo = { scope = 'event-processor', parameters = { Transpose = { type = 'interval', minValue = -24, maxValue = 24, defaultValue = 0, }, }}
-- END_INFO_BLOCK
local active = {} -- maps incoming event id -> created event id
function onProcessEvents(blockLen, events) for i, event in ipairs(events) do local t = scriptInfo.parameters.Transpose.value
if event.type == sys_event_types.noteOn then local newId = sys_create_note_event( event.offset, event.channel, event.note + t, event.velocity, nil) active[event.id] = newId sys_dispose_event(event.id)
elseif event.type == sys_event_types.noteOff then local newId = active[event.id] if newId then sys_set_note_length(event.offset, newId) active[event.id] = nil end sys_dispose_event(event.id) end endendWhat to Try Next
Section titled “What to Try Next”Once the transposer works, experiment with:
- Transposing down instead of up (negative offset)
- Randomising the offset within a range on each noteOn
- Only transposing notes above a certain pitch threshold
- Keeping the original note and adding a transposed copy alongside it — the seed of a harmonizer (covered fully in Examples)