Script examples
A cookbook. Every entry on this page is one of the sample scripts that ship
with the synth, in the script-samples folder and in the editor's sample
picker. The sample files are the single source of truth: this page is
generated from them, so what you read here is exactly what you can drop into
the editor.
For the language and primitives, see the overview. For per-handler details, see the handlers page.
Each entry shows the snippet wrapped in the handler it belongs to, then
explains what makes it worth writing as a script instead of a routing. A
Globals entry is a declaration you place in the Globals scope; pair it with
the handler that uses it.
Globals
Declarations that live in the Globals scope. A handler sample that reads
hits, note_count, or a seed expects the matching declaration here.
Per voice counter
voice var hits = 0
A per-voice counter that survives across note events for one voice. The mod matrix has no user-defined state - it can only sum modulation sources. Use this kind of variable to track "how often has THIS note been hit" or "what stage is this voice in".
Global counter
var note_count = 0
A global counter shared across every voice and every event. The mod matrix has no way to count notes - this is script-only.
Deterministic randomness
seed 42
Pin the random seed so a generative script renders identically on
every load and every offline bounce. Without a seed line the script
is reproducible by default; use seed live to randomise per session.
Progression step
var chord_step = 0
Which chord of the progression we are on. A script has memory; the mod matrix does not, which is why a chord progression cannot be built from modulation alone. Used by the "Chord progression" beat sample.
Melody walk
var walk_deg = 0
The current scale degree of the self-playing melody. Walking a DEGREE rather than a semitone is what keeps a generative line in key: the scale decides what the next note is allowed to be. Used by the "Scale-locked melody" beat sample.
Round robin
var rr_last = -1
var rr_pick = 0
The last variation chosen, so the next one can be guaranteed different. This is the memory a random modulator does not have, and the reason sample libraries script their round robins instead of rolling a die. Used by the "No two alike" note sample.
Phrase step
const phrase_beats = 1.0
var last_key_time = -99
var phrase_step = 0
How far into the current phrase we are, and when the last key went down. A phrase here is a run of keys played close together: leave a longer gap than phrase_beats and the next key starts a new run.
Nothing else in the synth remembers this. The mod matrix reads one note in isolation and the arpeggiator works from the clock, so neither can tell the third note of a fast run from the first note of a slow one. Only a script holds state between key presses.
last_key_time starts far in the past so the first key after Apply reads as the start of a phrase rather than the continuation of one.
Used by the "Phrase mutation" key sample.
on init
Runs once when the script loads. Prime global state or log a startup line.
One shot setup
on init
log("script loaded")
end
on init runs ONCE, immediately after the script loads. It is the
only event of its kind - useful for setup logging or for stamping a
global var with a starting value derived from the patch.
on key
Fires on key-down, before a voice exists and ahead of the chord builder and
the arpeggiator. The only place pitch and velocity can be rewritten, and
the only place a key press can be dropped with skip().
Play in key
on key
pitch = scale_quantize(pitch)
end
Never play a wrong note again.
Set the Key and Scale on the Chord page, then play anything. Every key you press is snapped to the nearest note that belongs to the key, so the black notes that are not in it simply play the white note beside them.
This is the sample that could not exist before on key. The on note
handler fires AFTER the voice has already started on the pitch you played,
so it is far too late to change it, and the mod matrix never sees a key
press at all: it only sees a note that is already sounding. on key runs
at the head of the note pipeline, before a voice exists, and pitch is
writable there.
What you write here is also what the chord builder and the arpeggiator act on, because they are downstream. Turn the Chord section on and the chord is built on the corrected note, not on the one your finger landed on.
Soft touch
on key
velocity = 127 * pow(velocity / 127, 0.6)
end
Reshape the velocity of a keyboard that plays too hard, or too soft.
Cheap controllers have a velocity curve you cannot change, and a bad one makes every note either 40 or 127 with nothing usable in between. Nothing in the mod matrix can fix that: velocity is a modulation SOURCE there, so you can route it, but you can never rewrite it.
on key runs before the voice exists, and velocity is writable there, so
the value the whole patch sees is the corrected one. Every velocity routing
in the matrix, and the amp envelope, and the arpeggiator's own steps all
read the curve you set here.
An exponent below 1 makes the keyboard easier to play loud, which suits a stiff action. Above 1 does the opposite, and gives you more room at the quiet end for expressive playing.
Keyboard split
on key
if pitch < 60
skip()
end
end
Answer only above middle C, so the left hand can drive something else.
A split is the ordinary way to play a bass with one hand and a lead with
the other from a single keyboard. Kiwisynth has no split control, and a
script does not need one: skip() drops a key press so it makes no note,
and it is the only thing that can, because it has to happen before a voice
is allocated.
Turn MIDI Out on and the notes below the split still reach your host, which is the point: this instrument ignores them, and whatever you have on the other track plays them.
skip() drops the NOTE, not the KEY. The engine still knows you are holding it, so held_count() keeps counting it and a chord you play across the split still reads as a chord to the rest of your script.
Strum
on key
after 22
hold(scale_quantize(pitch + 4), velocity * 0.9, 0)
end
after 44
hold(scale_quantize(pitch + 7), velocity * 0.8, 0)
end
end
Play a chord with one finger, and hear it strummed rather than stabbed.
Pair with the "Strum release" snippet in the release scope, which lets the added notes go when you lift the key.
A guitarist never sounds six strings at the same instant. The pick crosses them over twenty or thirty milliseconds, and that tiny spread is most of what the ear reads as "played" rather than "triggered". The Chord section builds a real chord from one key, but it builds it all at once, and the arpeggiator spreads notes forever rather than once. Neither can strum.
A script can, because on key runs at the moment of the press. The key you
played sounds straight away, and after schedules the other two chord tones
to arrive behind it. Falling velocity across the spread is the second half
of the illusion: the last string of a downstroke is always the quietest.
The tones are quantized into the key, so the chord comes out major on the first degree of a major key and minor on the second, without being told.
Use this INSTEAD of the Chord section, not with it: hold() feeds the same pipeline, so a chord builder downstream would build a chord on each of these notes in turn.
Phrase mutation
on key
if time - last_key_time > phrase_beats * 60 / bpm
phrase_step = 0
else
phrase_step = phrase_step + 1
end
last_key_time = time
# A scale step is one or two semitones depending on where in the key it falls,
# so moving two and snapping back into the key lands on the next degree either
# way. The climb is capped so a long run does not walk out of its register, and
# clamped because a pitch written outside 0..127 drops the note instead of
# being limited.
pitch = scale_quantize(pitch + min(phrase_step, 4) * 2)
pitch = clamp(pitch, 0, 127)
# Later notes of a run lean back, the way a played phrase loses weight as it
# goes. The clamp keeps a long run audible instead of fading to nothing.
velocity = clamp(velocity - phrase_step * 6, 1, 127)
end
Change each note by where it falls in the phrase, not by what the note is.
A patch can already shape itself against two clocks: time since the note started, which is what a motion contour and an envelope follow, and position in the bar, which is what a synced LFO follows. Neither can express "the third note of a fast run", because that depends on how long ago you played the previous key. A script is the only place that memory exists.
time is seconds since the script loaded, counted in samples rather than
from the wall clock, so this behaves the same offline as it does live and it
keeps working while the host transport is stopped. Measuring the gap against
bpm keeps the window musical: at the default a beat of silence ends the
phrase, and it stays a beat when the tempo changes.
The counting has to happen here rather than in on note. on key fires once
per physical key press. on note fires once per voice, and with the Chord
section on a single press becomes several voices, so counting there would
advance the step two or three times per key.
Because on key sits at the head of the note pipeline, the chord builder and
the arpeggiator act on the mutated note rather than on what the finger played.
Declarations live in the "Phrase step" globals sample.
Ghost notes
on key
if length > 0 and length < 120
velocity = velocity * 0.55
end
end
Play the short notes of a clip as ghost notes: quieter, under the line.
A bass or a funk guitar part is full of short notes that sit behind the beat rather than on it. A player plays them softer without thinking. A clip written at one velocity does not, and editing every short note by hand is the fix nobody wants.
length is how long the clip scheduled the note to last, in milliseconds,
known before the note starts. A key played live has no length and reads 0,
which is why the test is length > 0: a live note is left as you played it.
Because this runs in on key, the softer velocity is what the whole patch
sees: the amp envelope, every velocity routing, and the arpeggiator.
on note
Fires per voice at note-on, after the voice has started. Too late to change the pitch: decisions that depend on which note was played and how hard.
Velocity threshold
on note
if velocity > 100
# ramp(paramPath, targetOffset, durationMs) glides the script's
# offset to `targetOffset` over `durationMs`. The offset is added
# to the parameter's base value - here, +4000 Hz on filter cutoff.
ramp("filt_cutoff", 4000, 80)
end
end
Discrete branch on velocity: the mod matrix can only SCALE velocity (continuous, linear). A script can act only above a threshold - here, only hard hits open the filter.
Pitch bucket
on note
if pitch < 48 # below C3 - bass register
modulate("filt_reso", 0.3)
elif pitch < 72 # C3..B4 - mid register
modulate("filt_reso", 0.1)
end
end
Different behaviour per pitch range. The mod matrix has no notion of "if pitch is below 48" - it only multiplies a pitch-derived source. This sample adds resonance only to bass-register notes.
Pitch reference: 36 = C2 (bass), 48 = C3, 60 = C4 (middle C), 72 = C5, 84 = C6.
Bloom then decay
on note
ramp("filt_cutoff", 4000, 80) # open quickly: +4000 Hz over 80 ms
after 200 # 200 ms later...
ramp("filt_cutoff", 0, 600) # ...glide back to 0 over 600 ms
end
end
Time-staged behaviour: open the filter NOW, then close it 200 ms
later. after schedules a deferred block - the mod matrix has no
concept of "do X, then later do Y" for a single voice.
Count hits
on note
hits = hits + 1
if hits % 3 == 0 # every third hit
modulate("master_vol", 0.2)
end
end
Increment a per-voice counter on each note. Requires a matching
voice var hits = 0 in Globals (one slot per voice; persists for
that voice's lifetime).
Use the counter to drive behaviour the engine cannot - e.g. "every third hit on this voice is louder".
Relative pitch layer
on note
if velocity > 100 # only on hard hits
# play(pitch, velocity, durationMs) - softer than the main voice
# so the layer sits underneath rather than fighting it.
play(pitch + 7, 70, 250)
end
end
Add a one-shot harmony layer a perfect fifth above whatever key was just played. The mod matrix can transpose oscillators by a fixed interval, but it cannot spawn an extra voice at a relative pitch - that requires a script.
pitch is the bound variable from the note-on, so pitch + 7
(semitones) tracks the performance instead of sitting on a fixed
note. Useful for parallel-fifth leads, octave doublers (+ 12),
bass sub-octave layers (- 12), or chord stabs on accented hits.
Random pan scatter
on note
modulate("osc1_pan", (random() - 0.5) * 1.6)
end
Scatter every note to a different point in the stereo field. A per-note random offset is something the mod matrix cannot do - an S&H LFO is global and time-driven, not re-rolled per voice.
osc1_pan is -1 (hard left) .. +1 (hard right); (random() - 0.5) * 1.6 spreads notes across roughly 80% of the width, kept off the extremes.
Follow the key
on note
if held_count() == 1
set("scale_root", held_pitch(0) % 12)
end
end
Follow the player into whatever key they are playing in.
The Chord section builds diatonic chords and the Tuning retunes the thirds, but both need to know the key, and normally you set it by hand. This makes the synth work it out: the first key of each new phrase becomes the root, so the harmony re-centres on what you are actually playing without you touching a control.
held_count() is 1 only on the first key of a gesture, because the note handler runs after the key has been recorded. Once a second key goes down the phrase is under way and the root is left alone, so a chord does not keep re-rooting itself on every note of itself.
held_pitch(0) is the KEY that was pressed, not the note this voice is playing. With the Chord section on those differ: one key press becomes three voices, and this handler runs once per voice. Reading the key rather than the voice is what keeps the root correct either way.
No two alike
on note
rr_pick = random_int(0, 3)
if rr_pick == rr_last
rr_pick = (rr_pick + 1) % 4
end
rr_last = rr_pick
# Four takes of the same player: each a little different in timbre,
# placement and tuning, none of them ever twice in a row.
modulate("osc1_wt_pos", rr_pick * 0.08)
modulate("osc1_pan", (rr_pick - 1.5) * 0.25)
modulate("osc1_tune", (rr_pick - 1.5) * 3)
end
Stop repeated notes sounding like a machine gun.
Pair with the "Round robin" globals snippet, which declares rr_last and rr_pick.
A random modulator has no memory, so it repeats itself: roll a die often enough and the same face comes up twice in a row, and two identical notes in a row is precisely what the ear hears as "sampled". This is why sample libraries script their round robins rather than use the built-in random one.
A script has memory. Keep the last variation and pick a DIFFERENT one, so no two consecutive notes are ever alike. The mod matrix structurally cannot do this: a random source cannot see what it produced last time.
Harmonise in key
on note
play(scale_quantize(pitch + 4), velocity * 0.7, 400)
end
Add a harmony line a third above whatever you play, in the right key.
This is the sample that used to be impossible. Compare cc/01-wheel-triggers-chord, which fires a hardcoded C major triad: it has to, because a script could neither see what was held nor ask what a third is in the current key. It plays a C chord whatever you play.
scale_quantize() snaps a note into the key, so stepping up four semitones and quantizing gives a MAJOR third on the tonic and a MINOR third on the second degree, by itself, because those are the notes the key actually contains. Change the Key and this transposes with it. Nothing here is a hardcoded semitone.
Swell to the note end
on note
if length > 0
ramp("filt_cutoff", 3000, length)
else
ramp("filt_cutoff", 3000, 800)
end
end
Open the filter across exactly the length of the note.
A swell timed to the note is what a player does with a mod wheel: slow for
a long note, quick for a short one. An envelope cannot do it, because its
attack is one fixed time for every note. A clip knows how long each note
will be, and length hands that to the script, in milliseconds, so the
ramp arrives at the top just as the note ends.
A key played live has no length (0), so it gets a fixed swell instead.
on release
Fires per voice at note-off, while the voice is still in its release tail.
Post release echo
on release
after 200
# play(pitch, velocity, durationMs) - pitch+12 is one octave up
# from the released note, velocity 50 = soft, 120 ms duration.
play(pitch + 12, 50, 120)
end
end
Trigger another note 200 ms AFTER the user lifts the key. The synth's own release behaviour cannot fire a fresh note - only the script can.
Darken the tail
on release
ramp("filt_cutoff", -3000, 500)
end
Round off the release: as soon as the key is lifted, glide this voice's filter cutoff down so the tail fades darker than it played. The release envelope shapes level, not timbre - only a script can tie a cutoff move to note-off for the same voice.
Cutoff offsets are in Hz; -3000 over 500 ms pulls the brightness out gradually as the note rings down.
Strum release
on release
release(scale_quantize(pitch + 4))
release(scale_quantize(pitch + 7))
end
The companion to the "Strum" key snippet: let the strummed notes go.
The strum holds two extra chord tones on top of the key you pressed, and a
note a script holds stays held until the script releases it. The key press
itself is not skipped, so it makes a real voice, which means on release
fires for it when you lift the key. That is where the other two come home.
The arithmetic has to match the strum exactly, or a note is left hanging.
pitch here is the key that was lifted, the same value the strum started
from, so quantizing the same intervals lands on the same two notes.
on tick
Fires every 64 samples, the tightest cadence a script runs at. Shape modulation the LFOs cannot.
Stepped wavetable
on tick
modulate("osc1_wt_pos", floor(phase * 4) / 4)
end
Quantize the wavetable position to 4 discrete frames per beat. The mod matrix and built-in LFOs always interpolate smoothly between values - only a script can write a stepped, non-smooth target.
floor(phase * 4) is 0, 1, 2 or 3 across the beat. Dividing by 4 gives osc1_wt_pos values 0.0, 0.25, 0.5, 0.75 - a 4-step staircase.
Asymmetric pulse
on tick
if phase < 0.7 # first 70% of the beat
modulate("filt_cutoff", 2000)
else
modulate("filt_cutoff", -1000)
end
end
Asymmetric square: 70% of each beat the cutoff is up, 30% down. The built-in LFO square shape is fixed at 50/50 duty - only a script can shape its own pulse width like this.
Filter cutoff offsets are in Hz (raw parameter units).
Trance gate
on tick
if phase > 0.5
modulate("master_vol", -1)
else
modulate("master_vol", 0)
end
end
An eighth-note volume gate: full level for the first half of every beat, silenced for the second. The classic "trance gate" chop. A gate LFO could approximate it, but here the duty is a number you read and edit directly.
phase runs 0..1 across the beat. modulate latches and holds across blocks, so the open half must restore 0 explicitly - otherwise the -1 offset (which pulls master_vol to its 0 floor) would stick shut.
on beat
Fires on each musical beat boundary. The home for sequencers and play
calls that land on the grid.
Polyrhythm
on beat
if index % 4 == 0
play(36, 110, 200)
end
if index % 6 == 0
play(43, 80, 150)
end
end
Two independent rhythms running simultaneously - a kick every 4 beats and a snare every 6. The arpeggiator can only play ONE rhythm at a time; a script can interleave any number.
Evolving pattern
on beat
play(60 + (index % 12), 90, 100)
end
A pattern that changes WITH the bar - pitch climbs one semitone per beat. Neither the arpeggiator nor the motif player can derive note choice from a running global counter like this.
Random octave bass
on beat
play(36 + floor(random() * 13), 100, 160)
end
Fire a random bass note inside one octave on every beat. With the default fixed seed the sequence is identical on every playback, so a bounce and a live take agree - generative but reproducible. The arpeggiator and motif player follow a written pattern; this invents one from the RNG.
36 is C2; floor(random() * 13) adds 0..12 semitones (a full octave).
Chord progression
on beat
if beat_in_bar() < 1
# Let go of the previous chord before taking the next one.
release(degree(0))
release(degree(5))
release(degree(3))
release(degree(4))
# I - vi - IV - V: the four chords most records are made of.
if chord_step == 0
hold(degree(0), 100, 0)
elif chord_step == 1
hold(degree(5), 100, 0)
elif chord_step == 2
hold(degree(3), 100, 0)
else
hold(degree(4), 100, 0)
end
chord_step = (chord_step + 1) % 4
end
end
A chord progression that plays itself, and that the arpeggiator then arpeggiates. Turn the Chord section on and the arpeggiator on, hold nothing at all, and the patch is playing.
Pair with the "Progression step" globals snippet, which declares chord_step.
The division of labour is the whole point:
this script decides the HARMONY - which chord, by scale degree the Chord section decides the VOICING - what notes that chord has the arpeggiator decides the RHYTHM - the order and the timing
hold() is what makes it possible. play() bypasses both the Chord section and the arpeggiator, so a play()-driven progression can only ever be bare root notes. hold() is a virtual key press: it goes in where your fingers would, so everything downstream treats it as playing.
Degrees, not semitones. degree(0) is the tonic, degree(3) the fourth, degree(4) the fifth. Change the Key and this transposes with it; change the Scale to minor and the same progression turns minor, because the Chord section asks the scale what a third is.
Swing
on beat
play(degree(0), 100, 120)
after (30000 / bpm) * 1.18
play(degree(4), 80, 120)
end
end
Swing: push every second eighth note late so the pattern stops marching.
The arpeggiator has no swing control. Its steps are rigidly evenly
spaced, which is exactly what makes an arp sound like a machine. A
script can supply the missing feel, because after accepts any delay
you can compute, including one derived from the tempo.
The maths: a beat is 60000 / bpm milliseconds, so an eighth note is 30000 / bpm. Pushing the off-beat eighth later by a fraction of its own length is what a swing knob does on a drum machine. Multiply by 1.0 for straight, 1.33 for a triplet shuffle. 1.18, below, is the lazy feel most producers actually reach for.
Bar aware fill
on beat
if bar() % 8 == 7
# The bar before the turnaround: push.
set("arp_rate", 5) # 1/32, twice as fast
set("arp_ratchet", 3) # each step fires three times
modulate("filt_cutoff", 0.3)
else
# Everywhere else: the normal groove.
set("arp_rate", 2) # 1/16
set("arp_ratchet", 1)
modulate("filt_cutoff", 0)
end
end
Every eighth bar, do something different. The arrangement-scale version of what the arpeggiator's Chance and Ratchet do at step scale.
The arpeggiator knows where it is in its own pattern, but it has no idea what BAR the song is in, so it can never build to anything: it plays the same sixteen steps at bar 1 and at bar 63. That is what makes a generative patch tire the ear. Nothing ever arrives.
bar() counts bars from the start of the song and honours the host's time signature rather than assuming 4/4. Arp settings are ordinary parameters, so a script may drive them: here the last bar of every eight doubles the arp rate and adds a ratchet, which is a fill.
Scale locked melody
on beat
walk_deg = walk_deg + random_int(-2, 2)
if walk_deg > 4
walk_deg = walk_deg - 2
elif walk_deg < -4
walk_deg = walk_deg + 2
end
play(degree(walk_deg), 90, 200)
end
A melody that invents itself, stays in key, and bounces identically every time.
Pair with the "Melody walk" globals snippet (which declares walk_deg) and the "Deterministic randomness" one (which pins the seed).
Generative patches have a production problem: print the track twice and
you get two different takes, so you can never commit to the one you
liked. Kiwisynth is deterministic, so a seeded script renders the same
audio on every load and every offline bounce. That makes this melody a
real part of the patch rather than a happy accident you cannot keep.
Write seed live instead if you would rather it differed each session.
It walks by DEGREE, not by semitone, so it cannot play a wrong note: change the Key or the Scale and the melody follows. The step is small, and it is pulled back toward the tonic, which is what stops a random walk from wandering off and never coming home.
on cc
Fires when a Control Change arrives. Turn a controller into a trigger or a discrete switch the mod matrix cannot reach.
Wheel triggers chord
on cc(1)
if value > 0.5
play(60, 100, 200)
play(64, 100, 200)
play(67, 100, 200)
end
end
Crossing the wheel above half fires a chord. A CC in the mod matrix can only modulate - it cannot fire notes. Only a script can turn an incoming MIDI CC into a note trigger.
Discrete switch
on cc(1)
set("filt_type", floor(value * 6))
end
CC selects a discrete filter type (LP12 / LP24 / HP / ...). The mod
matrix never targets stepped/discrete parameters - only continuous
ones. set is script-only and the right tool for switch parameters.
on pitchbend
Fires on the pitch wheel. Reshape the wheel response beyond the built-in linear bend.
Cubic bend
on pitchbend
modulate("master_tune", value * value * value * 1200)
end
A non-linear pitchbend curve: gentle near the centre, aggressive at the extremes. The synth's built-in bend is strictly linear; the mod-matrix bend source is also linear.
on aftertouch
Fires on channel pressure. Turn pressure into a trigger instead of a modulator.
Pressure triggers note
on aftertouch
if value > 0.85
# play(pitch, velocity, durationMs) - pitch 72 = C5, vel 60 = soft,
# 80 ms before the synth's normal release takes over.
play(72, 60, 80)
end
end
Hard pressure (above 85% of max) fires a one-shot note. Aftertouch in the mod matrix can only act as a modulator; only a script can use it to TRIGGER a note when it crosses a threshold.
on transport
Fires on a host play/stop edge. React to the transport and reset script- owned state.
Counter reset
on transport
if playing
note_count = 0
log("transport started")
end
end
Reset the shared counter when the host hits play. Pair this with the
"Global counter" Globals sample, which declares var note_count = 0.
The arpeggiator and motif player react to transport but cannot reset
script-owned state - only the script can.
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,
and random() is seeded from the instance's seed, so an offline bounce and a
live take agree. The generative samples above (random() bass, scattered
pan, index-driven patterns) are reproducible for this reason; add seed live in Globals only when you want a patch to vary between takes.