uvi-script
Musical event scripting with Lua
Loading...
Searching...
No Matches
Using AI Assistants

Overview

Modern coding assistants — Claude Code, Cursor, Codex, and others — can be extremely effective at writing uvi-script, but they need a small amount of project-specific orientation to avoid common mistakes:

  • Picking the wrong Lua dialect (uvi-script is Lua 5.1, not LuaJIT or 5.4).
  • Calling functions that are not exposed by the sandbox (os.execute, io.open, io.read, …) or breaking the audio thread with busy-wait loops.
  • Mismanaging voice IDs, threading, or callback semantics.

The convention these tools follow is to look for an AGENTS.md file at the root of the workspace and treat its contents as standing instructions for the assistant.

How to use this

  1. Create a file at the root of your script folder (the same folder where your .lua scripts live). The filename depends on the assistant you use:
    • Claude Code → name it CLAUDE.md (most reliable; loaded automatically).
    • Cursor, Codex, and most other assistants → name it AGENTS.md.
    • If you use several assistants, just keep two copies of the same content.
  2. Copy the content of the block below into it, verbatim.
  3. Open your AI assistant in that folder.

The assistant will pick up the file automatically and use it as context for every prompt in that workspace. The content itself is tool-agnostic — only the filename differs.

Suggested content

Copy everything between the lines below into your CLAUDE.md or AGENTS.md (depending on the assistant — see the previous section):

# uvi-script — assistant guide

This workspace contains uvi-script Lua scripts. Each `.lua` file is a
standalone real-time event processor loaded by a UVI instrument. There is no
build step, no package manager, no module bundler — scripts are loaded as-is.

Full reference documentation:
https://lua.uvi.net

When you are unsure about a function signature or a parameter name, prefer
fetching the relevant page from the documentation above over guessing.

**Before writing any non-trivial script** — anything that resembles a
sequencer, arpeggiator, harmonizer, strummer, ensemble, generative
pattern, or voice-manipulation effect — start by browsing the working
example scripts at https://lua.uvi.net/_examples_page.html. The
canonical patterns for handling held-note sets, shared coroutines, voice
tracking, and multi-voice timing live there. Reusing one of those
skeletons is almost always better than reinventing the structure from
this guide alone.

---

## 1. Real-time safety (read this first)

Callbacks (`onNote`, `onRelease`, `onController`, …) execute on the audio
thread. Anything that blocks, allocates wildly, or syscalls there will glitch
the audio.

The sandbox does not expose blocking I/O or system access at all: the
entire `os` and `io` libraries are unavailable (no `os.execute`, no
`os.clock`, no `os.time`, no `io.open`, …), and `require` cannot reach
arbitrary modules. Do not try to call them — they are not "discouraged",
they are absent. For file I/O or dialogs, use the **Async** APIs instead:
`browseForFile`, `loadSample`, `loadMidi`, `saveData`, `loadData`. They run
on a background thread and call back into the script thread when done.

Inside a callback or any `spawn`-ed coroutine, also avoid:

- Busy-wait of any kind. The Lua-classic `while os.clock() < t do end`
  pattern would not even run here (`os` is unavailable), and any equivalent
  loop on `getTime()` would still freeze the thread. Use `wait(ms)` or
  `waitBeat(beats)` instead.
- Allocating large tables in a hot loop. Pre-allocate at script load time.
- Spamming `print` from a hot path — it is for debugging only.

## 2. Lua dialect

uvi-script runs **Lua 5.1**. This means:

- No LuaJIT, no FFI.
- No `goto` / labels (Lua 5.2+).
- No native bitwise operators (`&`, `|`, `~`, `<<`, `>>`). Use the provided
  `bit` helpers if you need bit math.
- Available standard libraries: `math`, `string`, `table`, plus a `bit`
  library for bitwise ops. The `os` and `io` libraries are **not** exposed
  at all — no `os.clock`, `os.time`, `os.date`, no `io.*`. For wall-clock
  or musical time, use the engine APIs `getTime()` (ms since script load)
  and `getBeatTime()` (current beat position).
- The garbage collector runs on the audio thread budget. Do not call
  `collectgarbage` aggressively.

## 3. Event-driven model

A script reacts to events by defining global callbacks. Names and casing
matter — the engine looks up these exact identifiers:

- `onInit()`              — called once when the script loads.
- `onNote(e)`             — incoming note-on.
- `onRelease(e)`          — note-off.
- `onController(e)`       — MIDI CC. Fields: `e.controller`, `e.value`.
- `onPitchBend(e)`        — pitch bend.
- `onAfterTouch(e)`       — channel aftertouch (note the camelCase).
- `onPolyAfterTouch(e)`   — polyphonic aftertouch.
- `onProgramChange(e)`    — MIDI program change.
- `onEvent(e)`            — generic catch-all; use `e.type` against
                             `Event.NoteOn` / `Event.NoteOff` to discriminate.
- `onTransport(playing)`  — host transport state changed (`playing` is a bool).
- `onSave()`              — return a table of state to persist with the preset.
- `onLoad(state)`         — restore state previously returned by `onSave`.

There is no `onIdle` callback — to run periodic work, use `spawn` with
`wait` / `waitBeat` (see threading section).

Note event fields (as carried by `onNote` / `onRelease`): `e.note`,
`e.velocity`, `e.layer`, `e.channel`, `e.input`, `e.vol`, `e.pan`, `e.tune`,
`e.slice`. When forwarding a note via `playNote`, real scripts often pass
the full set so the voice inherits the input's routing/expression.

**Forwarding rules.** If a callback is **defined** (even as an empty
function) the engine does NOT auto-forward — you decide whether to call
`postEvent(e)` or not. If a callback is **undefined**, the engine
auto-forwards the event via `postEvent(e)` for you.

This matters most for the NoteOn / NoteOff pair. If `onNote` swallows the
input (no `postEvent`), then `onRelease` must also be defined — at
minimum as an empty `function onRelease(e) end` — otherwise the engine
auto-forwards the matching NoteOff downstream and you get a spurious
release event for a voice your script never created.

To transform an event, modify `e` (or build a new event) and call
`postEvent(e)` from inside your defined callback.

## 4. Voices

A voice is one playing note. Voice manipulation functions take a `voiceId`,
which is returned when the voice is created:

```lua
function onNote(e)
  local id  = postEvent(e)              -- forward the incoming note, capture its voice
  local id2 = playNote(60, 100)         -- C4 vel=100, sustain until released
  local id3 = playNote(72, 100, 250)    -- C5 vel=100, auto-release after 250 ms
end
```

`playNote(note, vel, duration?, layer?, channel?, input?, vol?, pan?, tune?,
slice?, oscIndex?)` — only `note` and `vel` are required. Production scripts
that forward an incoming note often pass the full set so the new voice
inherits the input event's routing and expression:

```lua
local id = playNote(e.note, e.velocity, 0,
                    e.layer, e.channel, e.input,
                    e.vol, e.pan, e.tune, e.slice)
```

Once you have a `voiceId` you can use:

- `changeTune(id, cents, relative?, immediate?)`
- `changeVolume(id, gain01, relative?, immediate?)` / `changeVolumedB(id, dB)`
- `changePan(id, pan, relative?, immediate?)`           — pan in `[-1, 1]`
- `fadein(id, ms, period?)`
- `fadeout(id, ms, killVoice?, period?)`
- `releaseVoice(id)`

A `voiceId` becomes invalid after the voice ends (release or fadeout-with-kill).
Do not reuse it.

## 5. Cooperative threading

uvi-script uses cooperative coroutines, not OS threads. Two important
consequences:

- Each event callback (`onNote`, `onController`, …) already runs in its own
  coroutine. That means you can call `wait(ms)` or `waitBeat(beats)`
  **directly inside the callback** — you do not have to wrap it in `spawn`.
  The canonical "do something while the note is held" pattern is a `while
  isNoteHeld() do … wait(N) end` loop right inside `onNote` (see the
  vibrato example below).
- Use `spawn(fn, ...)` only when you need work that **outlives** the
  current event — e.g. a sequencer that keeps running across notes, or a
  watchdog. `spawn` returns immediately; the spawned coroutine yields via
  `wait` / `waitBeat` like any other.

`wait(ms)` and `waitBeat(beats)` are the only correct way to pause — never
busy-wait.

Useful query helpers from inside such loops: `isNoteHeld()` (true as long
as the note that triggered the current `onNote` is held), `isKeyDown(note)`
(true if a specific MIDI key is currently pressed).

## 6. UI widgets

Widgets are declared at the top level of the script (script-load time, not in
a callback). They appear as the script's user interface and **all of their
state is serialised automatically** with the preset — including the
contents of compound widgets like `Table` (every cell value is saved).
You do **not** need to copy widget values into `onSave` / `onLoad`; only
non-widget Lua state needs that (use `.persistent = false` to opt a
widget out of auto-persistence).

The convention in real scripts is to declare widgets as **globals**
(capitalised name) so they are reachable from any callback, then assign
`.changed` and call `:changed()` once to initialise display state:

```lua
Freq = Knob{"Freq", 4.0, 0, 10, unit = Unit.Hertz}
Freq.changed = function(self)
  -- ... react to value change ...
end
Freq:changed()                          -- init once at load
```

Common patterns:

- The integer flag is the 5th positional argument: `Knob("Steps", 4, 1, 16, true)`.
- The widget's first argument is its **internal parameter name** — it must
  be a valid identifier (letters, digits, underscore; **no spaces, no
  parentheses, no punctuation**). For a human-readable label with spaces or
  symbols, pass `displayName` as a keyword in the table-call form alongside
  `tooltip`, `unit`, `bounds`, etc.:

  ```lua
  PitchTable = Table{"PitchSt", MAX_STEPS, 0, -12, 12, true,
                     displayName = "Pitch (semitones)",
                     tooltip     = "Per-step pitch offset"}
  ```

  Using an invalid name like `Table{"Pitch (st)", ...}` errors at load with
  `'Pitch (st)' is not a valid parameter name`. Most widget options
  (`displayName`, `tooltip`, `unit`, `mapper`, `bounds`, `showLabel`,
  `showValue`, `backgroundColourOff`/`On`, …) can be set the same way —
  positional args first, keyword args after.
- Set `.tooltip` for hover help.
- Set `.persistent = false` on widgets whose state should not be saved
  with the preset (e.g. transient indicators).
- For value formatting, **prefer `unit = Unit.X`** in the constructor
  (table-call form) when the format is a standard one — the engine handles
  the display, suffix, and locale. Available values: `Unit.Hertz`,
  `Unit.Decibels`, `Unit.MilliSeconds`, `Unit.Seconds`, `Unit.Percent`,
  `Unit.PercentNormalized`, `Unit.SemiTones`, `Unit.Cents`, `Unit.Pan`,
  `Unit.LinearGain`, `Unit.MidiKey`, `Unit.Megabyte`, `Unit.Generic`. Fall
  back to setting `self.displayText` inside `.changed` only when you need a
  custom format the units don't cover.
- Mappers work the same way: `mapper = Mapper.Exponential` for log-style
  knobs (e.g. frequency). **`Mapper.Exponential` requires a strictly
  positive range** — `min` and `max` must both be > 0. A range like
  `0.0 .. 5.0` errors at construction with `'Exponential Mapper range
  should be strictly positive'`. When the range must include zero (e.g.
  ADSR times that should be allowed to go to 0), use **`Mapper.Cubic`**
  instead — it gives a similar curve-feel to Exponential but tolerates
  zero. `Mapper.Linear` is the other fallback if you don't want any
  curvature at all.

Widget types: `Knob`, `Slider`, `Button`, `OnOffButton`, `Menu`, `Label`,
`Image`, `Panel`, `Viewport`, `Table`, `XY`, `AudioMeter`, `WaveView`.


## 7. Engine access — navigating the synthesis tree

A pure MIDI processor often does not touch the engine, but as soon as you
need to drive synthesis parameters, swap samples, or build a custom patch
UI, you traverse the synthesis tree. Get this part wrong and the LLM will
invent methods (`getLayer(0)`, `addModulator()`, …) that do not exist.

**Root globals** (always available, no construction needed):

- `Synth`   — the master element (top of the tree).
- `Part`    — the current part (in multi-timbral contexts).
- `Program` — the current program / patch. **The most useful entry point**
              for almost every script.
- `Layer`   — context shorthand.

`Program` and `Part.program` refer to the same thing in a single-part
patch.

**Hierarchy and indexing**:

```
Synth.parts[i]              (1-indexed table of Part)
  → Part.program            (single Program reference)
    → Program.layers[i]     (1-indexed table of Layer)
      → Layer.keygroups[i]  (1-indexed table of Keygroup)
        → Keygroup.oscillators[i]   (1-indexed table of Oscillator)
```

All those collections are real Lua tables: index with `[i]` (Lua is
**1-based**), iterate with `ipairs(...)`, and use `#` to get the count.

**Effect chains and sends are 1-indexed arrays too.** On every level the
engine exposes:

- `.inserts[i]` — insert effects chain, in order. Index by integer.
- `.sends[i]`   — send buses, in order. Index by integer.
- `.auxs[i]`    — auxiliary effect buses (on `Synth` and `Program`).
                   Index by integer.

```lua
local reverb = Program.inserts[3]
keygroup.sends[2]:setParameter("Gain", 0.5)
Program.auxs[1].inserts[4]:setParameter("Volume", -6)
```

**Modulations are the only string-keyed collection.** `.modulations` is a
dictionary keyed by the modulator's display name (the name visible in the
UI):

```lua
local ampEnv  = Program.layers[1].keygroups[1].modulations["Amp. Env"]
local dahdsr  = Program.modulations["DAHDSR 2"]
local multiEg = keygroup.modulations["Multi Envelope 1"]
```

**Parent references.** `keygroup.layer`, `layer.program`,
`oscillator.keygroup` walk back up the tree without recomputing the path.

**Uniform parameter API.** Every node inherits from `Element` and exposes:

```lua
node:setParameter(name, value)
node:getParameter(name)
node:hasParameter(name)
node:getParameterConnections(name)   -- modulation/automation connections
```

Parameter names are strings (e.g. `"Pan"`, `"Gain"`, `"CoarseTune"`,
`"AttackTime"`, …). The canonical list per element type lives in the
**Elements** reference page of the documentation — when in doubt, fetch
it. Use `:hasParameter(name)` to guard the call:

- When you control both the script and the patch (you know the node
  type and the parameter name exists by construction), prefer
  `assert(node:hasParameter("X"))` at script-load time. Loud failure
  beats silent skip — if a future patch tweak removes the parameter,
  you find out immediately instead of silently no-op-ing.
- When the user can change the target node or the parameter name at
  runtime (e.g. a "pick any insert / any param" mapper), use a silent
  `if node:hasParameter(name) then node:setParameter(name, v) end` so
  invalid combinations are forgiving rather than fatal.

**The tree is stable.** Layers, keygroups, modulators, and effects are
configured in the patch — there is no public API to add or remove them
from a script (no `addLayer`, `addModulator`, `addEffect`). A script
*tweaks* existing nodes; it does not build the patch. Cache references
once at script-load time:

```lua
local kg     = Program.layers[1].keygroups[1]
local osc    = kg.oscillators[1]
local ampEnv = kg.modulations["Amp. Env"]

-- Idiomatic: walk a layer's keygroups, set a parameter on each.
for _, keygroup in ipairs(Program.layers[1].keygroups) do
  keygroup:setParameter("Pan", 0)
end

-- Drive an oscillator parameter from a UI knob.
Detune = Knob("Detune", 0, -100, 100)
Detune.changed = function(self) osc:setParameter("FineTune", self.value) end
Detune:changed()
```

**`loadSample`** targets a Sample Player oscillator, not a path or buffer:

```lua
loadSample(osc, "/path/to/sample.wav", function(task)
  if not task.success then print("load failed") end
end)
```

**Common LLM hallucinations to avoid**: `getLayer(i)` (use
`Program.layers[i]`), `addModulator(...)` / `addEffect(...)` (does not
exist — set parameters on existing modulators/inserts instead),
`Program:getLayers()` (it is a field `.layers`, not a method), 0-based
indices.

## 8. UI layout

The default Panel layout auto-flows child widgets — that works only if
the panel's size matches its content. Two rules to keep the UI from
looking broken:

1. **Build, then layout.** Declare all widgets and panels first. Set
   geometry at the END of the script in a single block, with relative
   positioning so panels flow consistently:

   ```lua
   local margin = 10

   oscPanel.x      = margin
   oscPanel.y      = margin
   oscPanel.width  = 400
   oscPanel.height = 60                           -- one row of knobs

   filterPanel.x      = oscPanel.x
   filterPanel.y      = oscPanel.y + oscPanel.height + margin
   filterPanel.width  = 400
   filterPanel.height = 60

   setSize(650, 380)                              -- overall window
   makePerformanceView()                          -- auto-arrange perf view
   ```

   Use the `.x` / `.y` / `.width` / `.height` properties separately
   instead of `panel.bounds = {x, y, w, h}` so later panels can refer to
   `oscPanel.y + oscPanel.height` cleanly.

2. **Size panels to fit their content.** The engine uses a 120-px column
   grid (one labelled widget per column). Practical rules:

   - labelled Knob, Menu, or Slider: **~120 px wide × ~45 px tall**
     (the engine default is `InnerColWidth × (2 * RowHeight + Margin)`
     which is 110 × 45, with 5 px margins on each side → 120 footprint)
   - Table: whatever you give it; typical 500 × 120
   - Label / single-line widget: ~120 × 20

   Panel width = `N * 120` for a row of N labelled widgets. Panel height
   for a knob row is ~60 (knob + small header padding). For panels with
   a Table + secondary row underneath, allow `tableH + 60`.

   The factory monoBassLine uses **400 / 400 / 500 / 630** across its
   four panels (3 knobs / 3 knobs / 4 knobs / Table+menu+knob); copy
   that scale rather than guessing tighter.

3. **`setSize(W, H)` must match the bounding box of all panels.** Compute
   from `math.max` of every panel's right edge plus a margin:

   ```lua
   local totalW = math.max(oscPanel.x  + oscPanel.width,
                           filtPanel.x + filtPanel.width,
                           envPanel.x  + envPanel.width,
                           seqPanel.x  + seqPanel.width) + margin
   local totalH = seqPanel.y + seqPanel.height + margin
   setSize(totalW, totalH)
   ```

4. **Inner widgets auto-flow.** Widgets created with `panel:Knob(...)`,
   `panel:Menu(...)`, etc. position themselves left-to-right within the
   panel as long as the panel is wide enough. Override with per-widget
   `.x` / `.y` / `.width` / `.height` only for non-flow placement
   (Tables, secondary indicators, etc.).

5. **`makePerformanceView()`** at the end of the script registers the
   currently-visible widgets as the performance view. Always call it
   for instrument scripts.

## 9. Examples

### Good: vibrato (LFO loop running while the note is held)

This is the canonical idiom in production scripts: drive an LFO inside the
`onNote` callback, sample the value at a fixed period with `wait(ms)`, and
apply it to the voice via `changeTune`. `isNoteHeld()` returns true as long
as the originating note is held, which makes the loop self-terminating.

```lua
Freq  = Knob("Freq",  4.0, 0, 10)   -- Hz
Depth = Knob("Depth", 25,  0, 100)  -- cents

function onNote(e)
  local id    = postEvent(e)
  local phase = 0
  while isNoteHeld() do
    changeTune(id, Depth.value * math.sin(2 * math.pi * phase))
    wait(5)
    phase = phase + (5 / 1000.0) * Freq.value
  end
end
```

### Good: velocity remap with a UI knob

```lua
Curve = Knob("Curve", 1.0, 0.25, 4.0)

function onNote(e)
  e.velocity = math.floor(127 * math.pow(e.velocity / 127, Curve.value) + 0.5)
  postEvent(e)
end
```

### Good: tracking voiceIds per held note

When several voices can play on the same note, track them in a table
indexed by note number so a later CC or release can address them:

```lua
heldVoices = {}

function onNote(e)
  local id = postEvent(e)
  heldVoices[e.note] = heldVoices[e.note] or {}
  table.insert(heldVoices[e.note], id)
end

function onRelease(e)
  heldVoices[e.note] = nil   -- voice ends naturally; ids are now invalid
end
```

### Good: persisting non-widget state with onSave / onLoad

Widgets serialise themselves (knob positions, table cells, menu selections,
…), so you only need `onSave` / `onLoad` for *Lua-side* state that has no
widget — or for widgets you deliberately opted out of auto-persistence
with `.persistent = false` and want to round-trip yourself. Real scripts
keep this minimal: return a table, restore from it, no defensive nil
handling:

```lua
recordedNotes = {}

function onSave()
  return { recordedNotes = recordedNotes }
end

function onLoad(data)
  recordedNotes = data.recordedNotes
end
```

### Bad: busy-wait — never do this

```lua
-- ❌ Won't even run: os is not exposed by the sandbox.
-- And even with a working clock, this would freeze the audio thread.
local t = os.clock() + 0.5
while os.clock() < t do end
```

```lua
-- ✅ Yield the coroutine. wait/waitBeat work directly inside callbacks.
wait(500)
```

### Bad: synchronous file I/O — not available in the sandbox

```lua
-- ❌ io.open / io.read / os.execute are not exposed; this will error.
local f = io.open("/path/to/file.wav", "rb")
```

```lua
-- ✅ Use the async API; the callback runs on the script thread.
-- loadSample requires a sample oscillator as its first argument — it loads
-- the file *into* that oscillator, it does not return audio data.
local osc = Program.layers[1].keygroups[1].oscillators[1]

browseForFile("open", "Select sample", "", "*.wav", function(task)
  if task.success then
    loadSample(osc, task.result, function(t)
      if t.success then print("loaded", task.result) end
    end)
  end
end)
```

## 10. When you are stuck

- **First, look at the example scripts** at
  https://lua.uvi.net/_examples_page.html. They cover most common
  patterns (arpeggiator, step sequencer, chord tools, harmonizer,
  ensemble, strummer, …) with idioms that already handle the subtle
  cases — held-note sets, shared coroutines, chord buffering, voice
  tracking. Adapting one is almost always better than building from
  scratch.
- For full signatures and parameter lists, fetch the relevant page from
  https://lua.uvi.net rather than guessing.
- Prefer reading existing `.lua` files in this workspace for project-local
  conventions before introducing new patterns.

Notes

This page is intentionally a summary. The authoritative documentation for every callback, widget, and engine parameter lives in the rest of this manual — see the Guides and API Quick Reference pages.