uvi-script
Musical event scripting with Lua
Loading...
Searching...
No Matches
Threading and Timing

Overview

uvi-script uses cooperative multithreading based on Lua coroutines. Each event callback runs as a lightweight micro-thread that can be suspended and resumed without blocking other threads or the audio processing.

This threading model enables complex, time-based behaviors while maintaining real-time performance guarantees.

Threading Model

Event Callbacks as Threads

Every event callback (onNote, onRelease, onController, etc.) executes as an independent thread:

function onNote(e)
-- Each note creates a new thread
local id = postEvent(e)
-- This loop runs independently for each note
while isNoteHeld() do
local lfo = math.sin(2 * math.pi * 5 * getTime() / 1000) -- 5 Hz vibrato
changeTune(id, 0.1 * lfo)
wait(5) -- Suspend this thread for 5ms
end
end
function getTime()
get the number of milliseconds elapsed since the script engine start.
Definition api.lua:740
function isNoteHeld()
return true is the note that created this callback is still held.
Definition api.lua:810
void onNote(table e)
event callback that will receive all incoming note-on events if defined.
function postEvent(e, delta)
send a script event back to the script engine event queue.
Definition api.lua:842
ScriptProcessor & this
Reference to the current ScriptProcessor instance.
Definition Engine.cpp:404
function wait(ms)
suspend the current thread callback execution for the given number of samples.
Definition wrapper.lua:39
function changeTune(voiceId, shift, relative, immediate)
change the tuning of specific voice in (fractionnal) semitones.
Definition api.lua:477

When multiple notes are played, each runs its own thread in parallel, maintaining separate state and execution flow.

Cooperative Scheduling

Threads are cooperative, meaning they must explicitly yield control using wait, waitBeat, or other waiting functions. A thread runs until it yields, and no other thread can execute in the meantime.

In practice this means every long-running loop needs a yield point:

-- BAD: infinite loop without yielding — blocks all note processing
function onNote(e)
while isNoteHeld() do
changeTune(e.id, math.random() * 0.1)
-- missing wait() here: nothing else can run!
end
end
-- GOOD: yield on every iteration
function onNote(e)
while isNoteHeld() do
changeTune(e.id, math.random() * 0.1)
wait(10) -- yield every 10 ms
end
end
function run(fun,...)
launch a function in a separate parallel execution thread.
Definition api.lua:1191

Thread-Local Variables

Use local variables within callbacks to maintain per-thread state:

function onNote(e)
local elapsed = 0 -- Separate for each note
local id = postEvent(e)
while isNoteHeld() do
elapsed = elapsed + 10
print("Note " .. e.note .. " held for " .. elapsed .. "ms")
wait(10)
end
end

Global variables are shared across all threads:

local noteCount = 0 -- Shared across all threads
function onNote(e)
noteCount = noteCount + 1
print("Total notes played: " .. noteCount)
end

Waiting Strategies

Wait in Milliseconds

Use wait to suspend execution for a precise number of milliseconds:

function onNote(e)
playNote(60, 100, 500) -- Play C4
wait(1000) -- Wait exactly 1 second
playNote(64, 100, 500) -- Play E4
end
function playNote(note, vel, duration, layer, channel, input, vol, pan, tune, slice, oscIndex)
helper function to generate a note event.
Definition api.lua:931

The wait function is sample-accurate and maintains precise timing even under CPU load.

Wait in Musical Time

Use waitBeat to suspend execution for a tempo-synchronized duration:

function onNote(e)
-- Create a tempo-synced arpeggio
playNote(e.note, e.velocity, 100)
waitBeat(0.25) -- Wait one sixteenth note
playNote(e.note + 4, e.velocity, 100)
waitBeat(0.25)
playNote(e.note + 7, e.velocity, 100)
waitBeat(0.5) -- Wait one eighth note
end
function waitBeat(beat)
Suspend execution for a tempo-synchronized duration in beats.
Definition conversions.lua:142

Common beat values:

  • waitBeat(0.25) - Sixteenth note
  • waitBeat(0.5) - Eighth note
  • waitBeat(1.0) - Quarter note (one beat)
  • waitBeat(2.0) - Half note
  • waitBeat(4.0) - Whole note

Wait for Note Release

Use waitForRelease to suspend until the triggering note is released:

function onNote(e)
local id = postEvent(e)
-- Wait for release, then play a release sample
playNote(e.note - 12, 50, 200) -- Play low release sound
end
function waitForRelease()
suspend the current thread callback execution until the note that created this callback is released e...
Definition wrapper.lua:47

Waiting for Conditions

Use isNoteHeld in loops to create conditional waiting:

function onNote(e)
local id = postEvent(e)
-- Vibrato that only plays while note is held
while isNoteHeld() do
local modulation = 0.1 * math.sin(2 * math.pi * 5 * getTime() / 1000)
changeTune(id, modulation)
wait(5)
end
end

Parallel Execution

Spawning New Threads

Use spawn to create new threads that run in parallel with the current one:

function onNote(e)
postEvent(e) -- Play the note
-- Spawn a vibrato thread that runs independently
spawn(function()
local duration = 0
while isNoteHeld() do
local modulation = 0.1 * math.sin(2 * math.pi * 5 * duration)
changeTune(e.id, modulation)
wait(5)
duration = duration + 0.005
end
end)
-- This callback completes immediately while vibrato continues
end
function spawn(fun,...)
Launch a function in a separate parallel execution thread (deferred execution)
Definition api.lua:1165

The spawned thread executes at the end of the current instant (callback cycle), so both the calling code and spawned function share the same initial timestamp.

Running Threads Immediately

Use run to execute a function immediately in a new thread:

function onNote(e)
-- Run starts the thread immediately
run(function()
for i = 1, 4 do
playNote(e.note + i * 12, e.velocity, 100)
waitBeat(0.5)
end
end)
end

Difference between spawn and run functions:

  • spawn() - Defers execution until end of current instant
  • run() - Starts execution immediately

Coordinating Multiple Threads

Create complex behaviors by spawning multiple coordinated threads:

function onNote(e)
local id = postEvent(e)
-- Thread 1: Vibrato
spawn(function()
while isNoteHeld() do
local vib = 0.1 * math.sin(2 * math.pi * 5 * getTime() / 1000)
changeTune(id, vib)
wait(5)
end
end)
-- Thread 2: Volume swell
spawn(function()
for vol = 0, 1, 0.01 do
changeVolume(id, vol)
wait(20)
end
end)
-- Thread 3: Panning oscillation
spawn(function()
while isNoteHeld() do
local pan = math.sin(2 * math.pi * 0.5 * getTime() / 1000)
changePan(id, pan)
wait(10)
end
end)
end
function changeVolume(voiceId, gain, relative, immediate)
changes a voice's volume.
Definition api.lua:519
function changePan(voiceId, pan, relative, immediate)
changes the pan position of a specific note event.
Definition api.lua:504

Time Query Functions

Getting Current Time

Use getTime to retrieve the current sample-accurate time in milliseconds:

function onNote(e)
local startTime = getTime()
wait(1000)
local elapsed = getTime() - startTime
print("Elapsed: " .. elapsed .. "ms") -- Will print ~1000ms
end

Getting Musical Context

Query musical timing information:

function onNote(e)
local tempo = getTempo() -- Current tempo in BPM
local beat = getBeatTime() -- Current beat position
local timeSig = getTimeSig() -- Time signature (e.g., "4/4")
local beatDuration = getBeatDuration() -- Duration of one beat in ms
print("Tempo: " .. tempo .. " BPM")
print("Beat: " .. beat)
print("Time sig: " .. timeSig)
end
function getBeatDuration()
return the duration of a beat in ms.
Definition api.lua:696
function getBeatTime()
get song position in beats.
Definition api.lua:748
function getTempo()
return the current tempo.
Definition api.lua:712

Common Timing Patterns

Tempo-Synced Arpeggio

local notes = {0, 4, 7, 12} -- Major chord
local step = 0
function onNote(e)
while isNoteHeld() do
local interval = notes[(step % #notes) + 1]
playNote(e.note + interval, e.velocity, 100)
waitBeat(0.25) -- Sixteenth notes
step = step + 1
end
end

Delay Effect

function onNote(e)
postEvent(e) -- Play original note
-- Spawn delayed echoes
spawn(function()
for i = 1, 4 do
wait(250 * i) -- Each echo 250ms apart
local vel = e.velocity * (0.7 ^ i) -- Decay velocity
playNote(e.note, vel, 200)
end
end)
end

Tremolo Effect

local tremoloFreq = Knob("Tremolo Rate", 5, 0, 20)
local tremoloDepth = Knob("Tremolo Depth", 0.5, 0, 1)
function onNote(e)
local id = postEvent(e)
spawn(function()
while isNoteHeld() do
local phase = 2 * math.pi * tremoloFreq.value * getTime() / 1000
local modulation = 1 - (tremoloDepth.value * (1 - math.cos(phase)) / 2)
changeVolume(id, modulation)
wait(5)
end
end)
end
Knob widget.
Definition ui.cpp:1424

Rhythmic Gate

function onNote(e)
local id = postEvent(e)
local gateOpen = true
spawn(function()
while isNoteHeld() do
if gateOpen then
changeVolume(id, 1.0)
else
changeVolume(id, 0.0)
end
gateOpen = not gateOpen
waitBeat(0.25) -- Toggle every sixteenth note
end
end)
end

Cancelling a Running Loop

When a new note should restart a sequencer or arpeggiator, you need to stop the previous loop cleanly. The standard pattern uses a shared counter: each loop checks whether its ID is still current before continuing.

local seqId = 0
function onNote(e)
seqId = seqId + 1 -- invalidate any running loop
local myId = seqId -- capture current ID
local step = 0
while myId == seqId do -- stop if a new note started
playNote(e.note + step, e.velocity, 200)
step = (step + 1) % 8
waitBeat(0.25)
end
end
function onRelease(e)
seqId = seqId + 1 -- also stop on release
end

This is safer than relying on isNoteHeld alone, because it handles the case where a new note arrives before the previous one is released (legato playing).

Musical Context Queries

Beyond basic time queries, the scripting API provides functions to inspect musical context, note state, and MIDI controller values. These are essential for building responsive instruments that react to performance in real time.

Duration Queries

Use getBarDuration and getBeatDuration to retrieve the current bar and beat durations in milliseconds. These update automatically when the host tempo changes. getSamplingRate returns the audio engine sample rate in Hz.

function onNote(e)
local barMs = getBarDuration() -- Full bar in ms (e.g. 2000 at 120 BPM, 4/4)
local beatMs = getBeatDuration() -- One beat in ms (e.g. 500 at 120 BPM)
local sr = getSamplingRate() -- Sample rate in Hz (e.g. 44100)
-- Compute a dotted-eighth delay time from the current tempo
local dottedEighth = beatMs * 0.75
print("Dotted eighth = " .. dottedEighth .. " ms")
end
function getBarDuration()
return the duration of a bar in ms.
Definition api.lua:688
function getSamplingRate()
return the current audio sampling rate.
Definition api.lua:704

Beat Position

getBeatTime returns the beat position reported by the host transport. It reflects the DAW playhead and jumps when the user relocates the cursor.

getRunningBeatTime uses an internal monotonic clock that keeps counting even when the host transport is stopped. This makes it suitable for free-running sequencers or arpeggios that should continue regardless of transport state.

function onNote(e)
-- Host-synced position — follows the DAW playhead
local hostBeat = getBeatTime()
-- Internal monotonic position — never resets or jumps
local runBeat = getRunningBeatTime()
print("Host beat: " .. hostBeat .. " Running beat: " .. runBeat)
end
function getRunningBeatTime()
get song position in beats.
Definition api.lua:757

Note and Key State

getNoteDuration returns the number of milliseconds elapsed since the last note-on for a given MIDI note number. isKeyDown returns true if a specific note is currently held, and isOctaveKeyDown returns true if any octave of that note class is held (e.g. any C).

-- Velocity-sensitive release: short notes get a staccato sample,
-- long notes get a sustain-release sample
function onRelease(e)
local held = getNoteDuration(e.note)
if held < 200 then
-- Short press — trigger staccato release layer
playNote(e.note, e.velocity, 150)
else
-- Long press — trigger sustain release layer
playNote(e.note, math.min(e.velocity, 80), 400)
end
end
function onNote(e)
-- Check if the octave below is also held (e.g. for split behaviour)
if isOctaveKeyDown(e.note) then
print("Another octave of this note is held")
end
end
function isOctaveKeyDown(note)
test if a note is down ignoring octave.
Definition api.lua:796
function getNoteDuration(note)
return the number of ms since the last note-on for the given note.
Definition api.lua:765
void onRelease(table e)
event callback executed whenever a note off message is received.

MIDI State

getCC returns the last received value (0–127) for any MIDI continuous controller number. This lets scripts read controller state at any time, not only inside onController.

local vibratoDepth = 0
function onNote(e)
local id = postEvent(e)
spawn(function()
while isNoteHeld() do
-- Read mod wheel (CC 1) to scale vibrato depth
local modWheel = getCC(1) / 127
local vib = modWheel * 0.5 * math.sin(2 * math.pi * 5 * getTime() / 1000)
changeTune(id, vib)
wait(5)
end
end)
end
function getCC(cc)
Retrieve the last control change value received for the given cc number.
Definition api.lua:731

Best Practices

Always Yield in Loops
Never create infinite loops without yielding:
-- BAD: will freeze the script — no yield point
function onNote(e)
while isNoteHeld() do
changeVolume(e.id, math.random())
end
end
-- GOOD: yields every 10 ms
function onNote(e)
while isNoteHeld() do
changeVolume(e.id, math.random())
wait(10)
end
end
Use Tempo-Synced Timing for Musical Events
For musical timing, prefer waitBeat over wait functions:
-- GOOD: Stays in sync with tempo changes
function onNote(e)
playNote(60, 100, 100)
waitBeat(1.0) -- Always one beat, regardless of tempo
playNote(64, 100, 100)
end
-- LESS IDEAL: Fixed time, doesn't adapt to tempo
function onNote(e)
playNote(60, 100, 100)
wait(500) -- Always 500ms, tempo-independent
playNote(64, 100, 100)
end
Store Voice IDs for Later Manipulation
Always store voice IDs returned by playNote or postEvent functions:
function onNote(e)
local id = postEvent(e) -- Store the voice ID
spawn(function()
wait(1000)
changeTune(id, 0.5) -- Can modify this specific voice
end)
end
Use Local Variables for Thread State
Keep per-thread state in local variables:
function onNote(e)
local elapsed = 0 -- Unique to this thread
local id = postEvent(e)
while isNoteHeld() do
elapsed = elapsed + 10
-- Use elapsed...
wait(10)
end
end

Transport State

The onTransport callback is called whenever the host transport state changes (play/stop). This is useful for synchronizing sequencers or arpeggios with the host:

function onTransport(playing)
if playing then
print("Transport started")
else
print("Transport stopped")
end
end
void onTransport(bool playing)
event callback that is called when the host transport bar state changes.

See Also

See also
Time and Threading - Time and Threading manipulation functions
wait - Suspend execution for milliseconds
waitBeat - Suspend execution for musical beats
waitForRelease - Wait until note is released
spawn - Create new thread (deferred)
run - Create new thread (immediate)
getTime - Get current time
getTempo - Get current tempo
getBeatTime - Get current beat position
isNoteHeld - Check if note is still held
getBarDuration - Get bar duration in milliseconds
getBeatDuration - Get beat duration in milliseconds
getSamplingRate - Get audio sample rate
getRunningBeatTime - Get monotonic beat position
getNoteDuration - Get time since note-on for a note
isKeyDown - Check if a specific note is held
isOctaveKeyDown - Check if any octave of a note is held
getCC - Get last value of a MIDI CC
Examples Gallery - Examples gallery