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.

Script examples

All docs / Kiwisynth / Script examples

Kiwisynth manual

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.