Early Preview — Kiwisonic is free during early preview. The shape of the product is still being figured out, and feedback is what drives it forward.

Scripting

All docs / Kiwisynth / Scripting

Scripting

The Script tab is for things the rest of the synth cannot do. A script runs alongside the engine and reacts to events: a note arrives, a CC moves, the host crosses a beat boundary. In response, the script can nudge a parameter, fire a note, or hold state across events. It never replaces the engine; it adds to it.

Scripts are saved with the patch. Loading a preset loads its script. Sharing a preset shares the script with it.

This page is the reference. For per-handler details see the handlers page. For worked examples see the examples page.

What scripting is and is not

A script is a modulation source. Whatever values it writes are added on top of the knob settings, the LFOs, and the envelopes. The knob still owns its value; the script only contributes a movement.

A script does not run per sample. It runs at events and at the start of each audio block, never inside the audio thread's inner loop. That means the script cannot make a sound on its own. To make a sound, it has to fire a note (play) or modulate a parameter that already produces sound.

A script cannot read other voices. Each note's script context is its own. Global state is shared, but per-voice state belongs to one voice.

The Script tab

The tab has three regions.

The editor on the left holds the script source. It carries syntax colouring, autocomplete, a parameter picker, and inline error markers next to any line that failed to parse.

The console at the bottom shows lines emitted by log(...). The console is a fixed-size ring; when the script logs faster than the console can draw, old lines are dropped. It is a diagnostics tool, not a logfile.

The footer carries the actions. Apply parses the script and brings the new version live. Any prior offsets the script had latched are cleared and on init runs again. Revert drops your edits and returns to the saved script. Raw toggles between Scoped and Raw modes (below).

Scoped and Raw modes

In Scoped mode the editor shows one handler at a time. A dropdown picks which: Globals, on init, on note, and the rest. The body you edit does not carry the on note ... end wrapper; that wrapper is added for you when the script is assembled.

Scoped is the default and the easier way in. Each handler is its own small body, and the Globals scope is where var, const, voice var, and seed declarations live.

Raw mode shows the whole script as one buffer, exactly as it is saved. Use Raw when you want to read your script top to bottom, or when you want to reorganise across handlers. Apply works the same in both modes.

Language

The language is small on purpose. It looks like BASIC: each statement is its own line, no braces, no semicolons, and an end closes any block that opened.

Comments start with # and run to the end of the line.

# This is a comment.
modulate("filt_cutoff", 1000)

Numbers and booleans

The only value types are numbers (double-precision floats) and booleans. There are no strings outside of parameter paths and log arguments, no arrays, no objects, and no user-defined functions.

true and false are the boolean literals. A condition can be any expression; non-zero numbers count as true.

Variables

There are three kinds.

var declares a global slot. One value, shared by every handler.

var note_count = 0

const is a global slot you cannot reassign. Use it for tuning constants.

const cutoff_floor = 200

voice var declares a per-voice slot. Each voice gets its own copy, initialised on note-on. Per-voice variables are visible only inside on note and on release.

voice var hits = 0

All declarations live in the Globals scope. Inside a handler you only assign to slots already declared.

Bound variables

Each handler injects a few read-only values for the event that fired it. pitch, velocity, channel, value, phase, index, time, bpm, playing. Which ones are available depends on the handler. See the handlers page for the per-handler list.

Bound variables cannot be assigned. Using one in a handler that does not expose it is a load-time error.

Operators

Group Operators
Arithmetic `+` `-` `*` `/` `%`
Comparison `==` `!=` `<` `>` `<=` `>=`
Logical `and` `or` `not`

Division by zero returns zero rather than faulting. Modulo follows the sign of the dividend.

Control flow

if / elif / else / end:

if velocity > 100
    modulate("filt_cutoff", 4000)
elif velocity > 60
    modulate("filt_cutoff", 1500)
else
    modulate("filt_cutoff", 0)
end

repeat <count> ... end runs the body a given number of times. The count is evaluated once. The cap is 256 iterations; a higher count is treated as 256.

repeat 8
    play(60, 80, 50)
end

after <ms> ... end defers a body to run later. The delay is in milliseconds. Deferred blocks are real: the rest of the handler keeps running, the audio keeps moving, and the body fires at the requested time on a later block. Use after to schedule a follow-up.

ramp("filt_cutoff", 4000, 80)
after 200
    ramp("filt_cutoff", 0, 600)
end

Built-in functions

Math:

Call Returns
`sin(x)`, `cos(x)` sine, cosine of radians
`abs(x)` absolute value
`min(a, b)`, `max(a, b)` smaller, larger of two values
`floor(x)`, `ceil(x)`, `round(x)` nearest integer
`clamp(x, lo, hi)` x limited to the range
`pow(base, exp)` base raised to exp
`sqrt(x)` square root (x must be non-negative)
`random()` uniform in `0..1`

Parameter primitives:

Call What it does
`modulate(path, amount)` latch a modulation offset on a continuous parameter
`ramp(path, target, ms)` glide a modulation offset to a target value over a duration
`set(path, value)` write a discrete or stepped parameter
`get(path)` read the base value of any parameter

Note triggering:

Call What it does
`play(pitch, velocity, ms)` start a note for a duration
`stop(pitch)` release the most recent voice on a pitch

Diagnostics:

Call What it does
`log(values...)` print one line to the console

Modulating, ramping, setting

The three writers are different in what they touch.

modulate(path, amount) writes the modulation lane. The amount is added on top of whatever the knob, the LFOs, and the envelopes are already contributing. A second call to modulate on the same path replaces the script's contribution; it does not stack. To remove a script's offset, call modulate(path, 0).

ramp(path, target, ms) writes the same lane but interpolates. It glides the script's offset from its current value to the target over the given milliseconds. Calling ramp again retargets the glide from where it currently is. Use ramp for time-shaped behaviour the envelopes cannot produce.

set(path, value) writes the base value of a discrete or stepped parameter, like the filter type or an LFO shape. These parameters do not accept modulation; they only accept a switch. set is the right tool for switch parameters and the wrong tool for continuous ones.

get(path) reads any parameter's base value. It returns what the knob says, not the current modulated value.

Parameter paths

Every parameter has a path: a short lowercase name in quotes. Examples: "filt_cutoff", "master_vol", "osc1_wt_pos", "filt_type".

You do not have to memorise paths. The script editor has a parameter picker: open it, drill into the same parameter groups you see in the knob layout, and click to insert the path string at the cursor. Autocomplete also offers paths as you type.

Paths resolve at Apply time. A typo is a load-time error: the line is flagged and the script does not run until you fix it.

A small set of parameters cannot be reached from a script. They are either structural (the master oscillator pipeline ordering, for example) or signature-character parameters that the instrument keeps fixed. Using a path that is blocked is a load-time error with a clear message.

The Script Depth knob

One knob in the synth header scales every modulate and ramp offset the script produces. At zero the script's modulation lane is silent; the script still runs, but its writes do not reach the audio. At the top, the script writes at full strength.

Script Depth is automatable and saved with the patch. Use it to dial back a downloaded preset's script without editing the code, or to sweep the script in and out over a section.

Script Depth does not touch set. Discrete writes go through at full strength regardless.

Note triggering

A script can play notes the keyboard never sent. play(pitch, velocity, ms) starts a note of the given MIDI pitch (0..127) and velocity (0..127), for the given duration in milliseconds. The synth's own envelope and release take over at the end of the duration, so a 20-ms play on a long-release pad still tails out naturally.

Played notes share the voice pool with the keyboard. If all voices are in use, a played note steals the oldest one, the same way a played note from the keyboard does.

stop(pitch) releases the most recently triggered voice on that pitch. It is the manual counterpart to play: if you do not want a duration, pass a long one to play and call stop when you decide to let go.

The arpeggiator and the motif player do not see play notes, and a script does not see arpeggiator output. The two layers are independent.

Determinism

By default, two listens to the same patch playing the same notes produce the same audio. The script clock counts samples since load, not wall-clock time, so an offline render and a live playback agree. random() is seeded from a fixed patch seed, so two runs see the same sequence.

A script can declare its own seed in Globals:

seed 42

seed 42 (or any 64-bit number) pins the seed across loads. seed live opts into entropy: the seed is drawn from the runtime when the patch loads, so two playbacks of the same patch differ. Use seed live for a script that intentionally varies between takes; otherwise, leave the default in place.

Safety

A script cannot crash the audio thread.

Loops are bounded: repeat is capped at 256 iterations, and the language has no while. A handler has a statement budget; the script as a whole has a per-block budget. Running into either budget aborts the current invocation, not the script, and prints a message to the console.

A runtime fault inside one handler invocation, like reading a stopped voice, aborts only that invocation. Already-latched offsets stay where they were and the script keeps running for the next event.

There is no file or network access. There are no user-defined functions and no recursion. The set of things a script can do is exactly the built-ins listed above, and nothing else.

When to use a script

Use a script when the mod matrix and the envelopes do not reach the behaviour you want.

Some things scripts can do that the rest of the synth cannot:

  • Branch on a threshold (only above velocity 100, only below pitch 48).
  • Hold state across events (count hits on a voice, advance a step counter on each beat).
  • Schedule time-staged behaviour on a single event (ramp up now, ramp down later).
  • Turn a CC or aftertouch into a note trigger instead of a modulator.
  • Drive a discrete parameter from a continuous source.
  • Run two rhythms at once on the same patch, beyond what the arpeggiator's one-rhythm-at-a-time model allows.

The cookbook on the examples page shows each of these patterns in code.