Early Preview · Kiwisonic, Kiwisynth, Kiwipulse and Kiwibass are free during early preview. Their shape is still being figured out, and feedback is what drives them forward.

Scripting

All docs / Kiwisynth / Scripting

Kiwisynth manual

Scripting

The Script page 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.

It is the third page of Perform, next to Chord and Arp. A script is one of the three things in Kiwisynth that generate notes, and it can feed the other two, so it lives with them.

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 page

The page 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, length, 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.

The body runs detached from the event, but the event's bound variables are frozen when after is reached and replayed when the body fires. So on release / after 200 / play(pitch + 12, ...) plays an octave above the note that was released, not whatever happens to be sounding 200 ms later. The clock variables (bpm, time, playing) are the exception: they read live at fire time, not the value captured earlier.

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`
`random_int(lo, hi)` a whole number from `lo` to `hi`, both included

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, straight at the voices
`stop(pitch)` release the most recent voice on a pitch
`hold(pitch, velocity, ms)` press a virtual key into the note pipeline
`release(pitch)` lift a virtual key
`skip()` drop the key press being handled (`on key` only)

Reading what is held:

Call Returns
`held_count()` how many keys are down
`held_pitch(i)` the pitch of the i-th held key
`chord_size()` how many notes the Chord stage is sounding
`chord_tone(i)` the pitch of the i-th note it built

Reading the key:

Call Returns
`scale_quantize(pitch)` the nearest pitch that is in the key
`in_scale(pitch)` true when the pitch is in the key
`degree(n)` the pitch of scale degree n (0 is the root)

Musical position:

Call Returns
`bar()` the bar number, counted from the start of the song
`beat_in_bar()` the position within the bar, in beats

Both read the host's transport and honour its time signature. Without a host transport they return 0.

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 on the Script page 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.

Playing a note and pressing a key are different things

play goes straight to the voices. It skips the Chord stage and the arpeggiator, so a played note sounds exactly as written and nothing downstream transforms it.

hold(pitch, velocity, ms) does the other thing: it presses a virtual key at the head of the note pipeline. The Chord stage builds a chord from it, the arpeggiator arpeggiates that chord, and what you hear is what those stages made of it. release(pitch) lifts the key by hand, the way stop does for play.

That is the difference worth knowing. Use play when the script is the instrument. Use hold when the script is the player: it chooses the harmony, the Chord stage chooses the voicing, the arpeggiator chooses the rhythm.

A note the script generates never comes back into the script. hold does not fire the note handler, and the arpeggiator's output does not either. A script cannot trigger itself, by either route.

Notes the script holds are not echoed to your host as your own playing. What the Chord stage and the arpeggiator make of them is, when MIDI Out is on.

Changing the note before it is played

on note runs after the voice has already started. That is the right place to shape a note, and the wrong place to change which note it is: by then the pitch is committed and the chord builder and the arpeggiator have already seen it.

on key runs before any of that. It fires on the key press itself, at the head of the pipeline, and it is the one handler where pitch and velocity are writable.

on key
    pitch = scale_quantize(pitch)   # never play a wrong note again
    velocity = velocity * 0.8       # soften a keyboard that hits hard
end

Whatever it leaves behind is what the rest of the instrument acts on. Quantize the pitch and the chord builder builds its chord on the corrected note. Reshape the velocity and every velocity routing in the matrix reads the new curve.

skip() drops the key press so it makes no note at all, which is how a keyboard split or a note filter is written. It drops the note, not the key: the instrument still knows your finger is down, so held_count() keeps counting it.

on key is a global handler, because there is no voice yet. So a voice var is not available in it, and modulate from it writes the global lane, which moves every sounding voice. To shape the note that is arriving, use on note. The two handlers are a pair, and the split between them is simply which side of the voice they run on.

Writing a stage from a note handler lands one note late

The Chord stage and the arpeggiator run before a voice exists, so they read their settings before the note handler for that key has run. Setting chord_type or an arp setting from on note therefore takes effect on the next key, not the one that just triggered the handler.

Set the harmony ahead of the keys, from on beat, on cc, on tick or on init, and play into it. Those handlers are global: they run before any note is allocated in the block, so what they write is what the stages see.

Working in the key

A script can ask what you are holding and what the key is, which is what separates a script that plays a fixed pattern from one that plays with you.

held_count() and held_pitch(i) read the keys that are down, so a script can build an interval against what you are actually playing rather than against a note it was written with.

scale_quantize(pitch) snaps a pitch into the key set on the Chord page. Step up four semitones from a note and quantize, and you get a major third on the tonic and a minor third on the second degree, because those are the notes the key contains. Change the Key and the whole script transposes with it. Nothing is a hardcoded semitone.

degree(n) goes the other way: give it a scale degree and it gives back a pitch. A chord progression written as degrees is a progression that works in any key you later choose.

chord_size() and chord_tone(i) read what the Chord stage built, so a script can arrange a chord it did not have to construct.

The language has no arrays, and it is not getting any. It does not need them for this: the engine owns the held keys, the scale and the chord, and hands them out one at a time by index.

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 the instance's seed, so two runs see the same sequence. In a Kiwi host every track has a seed of its own, so two tracks with the same patch draw different numbers; elsewhere the seed is fixed. Inside on note and on release, random() draws from the note's own seed instead, so a note from a clip rolls the same numbers every time it plays.

A script can declare its own seed in Globals:

seed 42

seed 42 (or any 64-bit number) picks a variation: the same number gives the same sequence on every load, and a different number gives a different one. It is combined with the instance's seed, so two tracks still differ. seed live opts into entropy: the seed is drawn when the patch loads, so two playbacks of the same patch differ, and the host is told this sound does not render the same twice. 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.