uvi-script
Musical event scripting with Lua
Loading...
Searching...
No Matches
Porting from Kontakt (KSP)

If you have written scripts for Native Instruments Kontakt, you already know the hard parts: event-driven thinking, voice management, tempo-synced timing, building instrument UIs. This guide maps that knowledge onto uvi-script, concept by concept, and flags the places where the two environments genuinely differ — including unit conversions that will silently break a naive line-by-line port.

Each section shows the KSP idiom first, then the uvi-script equivalent. You do not need to read it in order; jump to the topic you are porting.

The big picture

The single most important difference: KSP is a domain-specific language, uvi-script is real Lua 5.1 with a music API on top. Most of the KSP workarounds you have internalized (polyphonic variables, functions without arguments, wait-based state machines guarded by callback IDs) simply disappear.

Concept KSP uvi-script
Language KSP dialect Lua 5.1 (real functions, tables, closures)
Variables declare $x in on init, $/%/~/?/@/! sigils plain Lua variables and tables, declared anywhere
Numbers separate integer / real types a single number type
Callbacks on noteend on function onNote(e) … end
Event data built-in variables ($EVENT_NOTE, …) an event table e (e.note, e.velocity, …)
Per-voice state declare polyphonic $x local variables (each callback runs in its own coroutine)
Periodic work on listener + set_listener() spawn() a loop with wait() / waitBeat()
Engine access set_engine_par($ENGINE_PAR_…, value, group, slot, generic) object tree: Program.layers[1].keygroups[1].inserts[1]:setParameter("Freq", 5000)
Engine values normalized integers 0 … 1000000 real-world values (Hz, dB, seconds, …)
UI widgets declare ui_knob $k (0, 100, 1) + on ui_control widget objects: k = Knob("Gain", 0, 0, 100) + k.changed callback
Persistence make_persistent() per variable automatic for widgets; onSave / onLoad for the rest
Script slots / chaining 5 slots, set_event_par to communicate one script usually does the whole job

Unit cheat sheet

KSP expresses most quantities as scaled integers; uvi-script uses floating point in natural units. These conversions are the most common source of silent porting bugs — bookmark this table.

Quantity KSP uvi-script
Wait / fade durations microseconds (wait(500000)) milliseconds (wait(500))
Tuning millicents (change_tune(id, 100000, 0) = 1 semitone) fractional semitones (changeTune(id, 1.0)) — 0.01 = 1 cent
Volume millidecibels (change_vol(id, -6000, 0)) decibels (changeVolumedB(id, -6)) or linear gain (changeVolume(id, 0.5))
Pan -1000 … 1000 -1.0 … 1.0
Pitch bend -8192 … 8191 -1.0 … 1.0
Musical durations $DURATION_QUARTER (µs), MIDI ticks (960/quarter) beats: waitBeat(1), beat2ms(beats), ms2beat(ms)
Engine parameters 0 … 1000000 normalized natural units (Hz, dB, s, …) per parameter
Uptime clock $ENGINE_UPTIME (ms) getTime() (ms) — this one matches
Array / collection indices 0-based 1-based (Lua convention)

The language: from KSP to Lua

Everything you declared with sigils becomes a plain Lua value. There is no declare, no on init requirement for declarations, no integer/real split, and arrays are Lua tables — dynamic, nestable, and 1-based.

KSP:

on init
declare $count := 0
declare ~gain := 0.5
declare @name
declare %steps[16]
declare !labels[4]
$count := $count + 1
@name := "step " & $count
end on

uvi-script:

local count = 0
local gain = 0.5
local steps = {} -- grows as needed; steps[1] is the first element
local labels = { "A", "B", "C", "D" }
count = count + 1
local name = "step " .. count -- '..' concatenates, '&' does not exist

Operator and syntax translation:

KSP Lua
:= (assignment) =
= (comparison) ==
# (not equal) ~=
& (string concat) ..
x mod y x % y
.and. / .or. / .xor. / .not. / sh_left / sh_right (bitwise) Lua 5.1 has no native bitwise operators; the runtime provides a bit helper library
a xor b (logical) a ~= b (on booleans)
and / or / not (logical) and / or / not
if … end if if … then … end
else + nested if elseif
select() if/elseif chain, or a table used as a jump table
while … end while while … do … end, plus real for loops
exit return (leaves the callback — or the function, same nuance as KSP)
continue none in Lua 5.1 — restructure the loop, or wrap the body in repeat … break … until true
in_range(x, y, z) x >= y and x <= z
declare const $X := 5 a plain local X = 5
abs(), pow(), sqrt(), exp(), log(), ceil(), sin(), … the whole KSP math set maps to Lua's math library; round()math.floor(x + 0.5)
num_elements(a) #a
sort(a, dir) table.sort(a) / table.sort(a, comparator)
search(a, v) / array_equal(a, b) no built-in — a plain for loop over the table
{ comment } -- comment / --[[ block ]]
random(min, max) math.random(min, max)
int() / real() unnecessary — one number type; math.floor when you need truncation
message("…") print("…") (console, debugging) or a Label for user-facing text

KSP functions cannot take arguments or return values; Lua functions do both, and recursion, locals, and closures all work:

KSP:

on init
declare $root_note
end on
function play_triad()
play_note($root_note, 100, 0, 300000)
play_note($root_note + 4, 100, 0, 300000)
play_note($root_note + 7, 100, 0, 300000)
end function
on note
$root_note := $EVENT_NOTE
call play_triad()
end on

uvi-script:

local function playTriad(root, velocity, ms)
for _, interval in ipairs({0, 4, 7}) do
playNote(root + interval, velocity, ms)
end
end
function onNote(e)
playTriad(e.note, e.velocity, 300)
end
function onRelease(e) end -- note-on was swallowed, swallow note-off too
void onNote(table e)
event callback that will receive all incoming note-on events if defined.
void onRelease(table e)
event callback executed whenever a note off message is received.
function playNote(note, vel, duration, layer, channel, input, vol, pan, tune, slice, oscIndex)
helper function to generate a note event.
Definition api.lua:965

KSP's 10-million-iteration while guard becomes a watchdog: a callback that executes on the order of a billion consecutive instructions without yielding is aborted with an "infinite loop?" error. A loop on the script thread must yield with wait() / waitBeat() or finish quickly — see Threading and Timing for the threading model.

Note
Most commercial KSP scripts are written against a preprocessor (SublimeKSP / KScript) rather than raw KSP: macro / iterate_macro, USE_CODE_IF, SET_CONDITION, define, function with arguments, and literal arrays are all expanded away before Kontakt ever sees them. Port from the compiled output if you have it — otherwise expand the constructs yourself first, mapping macros and defines to real Lua functions, locals and loops. Lua gives you the language features the preprocessor was faking, so the expanded form is usually shorter than the source.

Callbacks and event flow

The callback set maps almost one to one. Event data arrives as a Lua table e instead of built-in variables:

KSP uvi-script
on init top-level script code (runs at load), plus onInit() if needed
on note onNote(e)e.note, e.velocity, e.channel, …
on release onRelease(e)
on controller (CC via $CC_NUM) onController(e)e.controller, e.value
on controller ($VCC_PITCH_BEND) onPitchBend(e)e.bend in [-1;1]
on controller ($VCC_MONO_AT) onAfterTouch(e)e.value
on poly_at onPolyAfterTouch(e)
onProgramChange(e)
on listener (timer signals) a spawn()-ed loop — see below
on listener (transport signals) onTransport(playing)
on ui_control (<widget>) the widget's changed callback — see UI section
on ui_controls (global UI callback) assign the same Lua function to several widgets' changed
on ui_update not needed — use parameter-bound widgets
on persistence_changed onLoad(data)
on async_complete per-call completion callbacks — see Files & async
on midi_in (multi script) onEvent(e) — master callback for every event type
on rpn / on nrpn, on note_controller, on pgs_changed no direct equivalent — see gaps

Note that KSP's single on controller callback is split into three: CC, pitch bend, and channel pressure each get their own callback, already decoded — no $VCC_* discrimination needed.

ignore_event() is inverted: postEvent()

This is the most important behavioural difference in the event model, and it inverts KSP's default:

  • In KSP, an incoming event passes through unless you call ignore_event().
  • In uvi-script, as soon as you define a callback, nothing is forwarded unless you call postEvent(e). (Callbacks you do not define auto-forward, so an empty script is still transparent.)

So ignore_event($EVENT_ID) translates to simply not calling postEvent(e) — and a passthrough on note … end on needs an explicit postEvent(e).

One trap follows from this rule: if onNote swallows the note-on, you must also define onRelease (even empty), otherwise the engine auto-forwards the matching note-off and the instrument receives a release for a note it never saw:

function onNote(e)
-- swallowed: transformed notes are played manually instead
playNote(e.note + 12, e.velocity, -1)
end
function onRelease(e) end -- required: swallow the matching note-off too

To transform an event in place (KSP change_note() / change_velo()), modify the table before forwarding — no "only before the first wait()" restriction, because the event is not dispatched until you post it:

KSP:

on note
change_velo($EVENT_ID, 100)
change_note($EVENT_ID, $EVENT_NOTE + 12)
end on

uvi-script:

function onNote(e)
e.velocity = 100
e.note = e.note + 12
end
function postEvent(e, delta)
send a script event back to the script engine event queue.
Definition api.lua:857

Polyphonic variables become local variables

Every callback invocation runs in its own coroutine (see Threading and Timing), so a plain local behaves like declare polyphonic did — each note gets its own copy across wait() calls, with none of the 32 KB-per-variable cost:

KSP:

on init
declare polyphonic $a
end on
on note
ignore_event($EVENT_ID)
$a := 0
while ($a < 13 and $NOTE_HELD = 1)
play_note($EVENT_NOTE + $a, $EVENT_VELOCITY, 0, $DURATION_QUARTER / 2)
inc($a)
wait($DURATION_QUARTER)
end while
end on
function wait(ms)
suspend the current thread callback execution for the given number of milliseconds.
Definition wrapper.lua:39

uvi-script:

function onNote(e)
local a = 0 -- per-note by construction
while a < 13 and isNoteHeld() do
playNote(e.note + a, e.velocity, beat2ms(0.5))
a = a + 1
end
end
function onRelease(e) end -- note-on was swallowed, swallow note-off too
function isNoteHeld()
return true is the note that created this callback is still held.
Definition api.lua:821
function beat2ms(beat)
Convert beat duration to milliseconds based on the current tempo.
Definition conversions.lua:38
function waitBeat(beat)
Suspend execution for a tempo-synchronized duration in beats.
Definition conversions.lua:142

Global variables keep the role of KSP's regular variables: shared across all callbacks.

Timing, wait() and the listener

wait() exists with the same semantics — it suspends the current callback while others keep running — but takes milliseconds, not microseconds. wait(500000) in KSP is wait(500) here. Port the value, not just the call.

Tempo-synced code drops MIDI ticks and $DURATION_* constants in favour of beats:

KSP uvi-script
wait($DURATION_QUARTER) waitBeat(1)
wait_ticks(480) (960 = quarter) waitBeat(0.5)
$DURATION_QUARTER (µs) getBeatDuration() (ms) or beat2ms(1)
$DURATION_BAR getBarDuration()
ms_to_ticks() / ticks_to_ms() (µs despite the names!) ms2beat() / beat2ms() — real milliseconds
$NI_SONG_POSITION (ticks) getBeatTime() (beats, follows the host) — or getRunningBeatTime() for a monotonic position that keeps counting when the transport is stopped
$ENGINE_UPTIME (ms) getTime() (ms)
NOTE_DURATION[note] getNoteDuration(note) (ms since the last note-on)
$NOTE_HELD isNoteHeld()
KEY_DOWN[note] isKeyDown(note)
CC[num] getCC(num)
stop_wait(id, mode) no direct equivalent — make loop conditions check shared state instead
reset_ksp_timer / $KSP_TIMER (µs) keep a local t0 = getTime() and subtract (ms)
$SIGNATURE_NUM / $SIGNATURE_DENOM getTimeSignature()

The on listener machinery (a single timer you configure with set_listener() and discriminate with $NI_SIGNAL_TYPE) is replaced by spawning as many independent loops as you need, each with its own period — spawn() starts a coroutine that outlives the current callback:

KSP:

on init
declare ui_value_edit $Tempo (20, 300, 1)
declare ui_switch $Play
$Tempo := 120
set_listener($NI_SIGNAL_TIMER_MS, 60000000 / $Tempo)
end on
on listener
if ($NI_SIGNAL_TYPE = $NI_SIGNAL_TIMER_MS and $Play = 1)
play_note(60, 127, 0, $DURATION_EIGHTH)
end if
end on
on ui_control($Tempo)
change_listener_par($NI_SIGNAL_TIMER_MS, 60000000 / $Tempo)
end on

uvi-script:

Play = OnOffButton("Play", false)
local playId = 0 -- epoch token: bumping it retires older loops
Play.changed = function(self)
playId = playId + 1
if self.value then
local myId = playId
spawn(function()
while playId == myId do
playNote(60, 127, beat2ms(0.5))
waitBeat(1) -- follows the host tempo, no re-arming needed
end
end)
end
end
2 states boolean button.
Definition ui.cpp:963
function spawn(fun,...)
Launch a function in a separate parallel execution thread (deferred execution)
Definition api.lua:1201

The widget callback starts the loop, and the epoch token stops it: every Play.changed bumps playId, so a running loop notices playId ~= myId after its current waitBeat and exits — the uvi-script answer to stop_wait(). The token (rather than testing Play.value directly) also guarantees that a quick off/on toggle never leaves two loops running: the new spawn gets a fresh ID, the old one dies at its next wake-up. The Arpeggiator example uses the same pattern. Each waitBeat call reads the current host tempo, so with short periods like this the KSP habit of re-arming the timer from on ui_control disappears (a single long waitBeat will not follow a tempo change mid-wait, though).

Note the intentional semantic change: the KSP original free-runs from its own $Tempo knob, while this port follows the host. If you want the independent rate, keep the knob and use wait(60000 / Tempo.value) as the loop period instead of waitBeat. Transport start/stop signals ($NI_SIGNAL_TRANSP_START / _STOP) become the onTransport(playing) callback. See Threading and Timing for patterns like beat-aligned sequencers.

Notes, events and voices

$EVENT_ID becomes a voice ID, returned by postEvent(e) and playNote(...). Everything KSP does with event IDs — tune, volume, pan, fades, release — takes that ID:

KSP uvi-script
$new_id := play_note(note, vel, offset_µs, dur_µs) local id = playNote(note, vel, dur_ms, layer, …)
play_note(…, -1) (release with triggering note) playNote(note, vel, -1) — same convention, and it is the default
play_note(…, 0) (plays the entire sample) closest: playNote(note, vel, 0) — the voice runs until you call releaseVoice(id)
sample offset argument (µs) setSampleOffset(id, ms)
note_off($new_id) releaseVoice(id)
change_tune(id, millicents, rel) changeTune(id, semitones, relative, immediate)
change_vol(id, millidB, rel) changeVolumedB(id, dB, relative, immediate) / changeVolume(id, gain)
change_pan(id, -1000…1000, rel) changePan(id, pan) with pan in [-1;1]
fade_in(id, µs) fadein(id, ms)
fade_out(id, µs, stop) fadeout(id, ms, killVoice)
change_note() / change_velo() edit e.note / e.velocity before postEvent(e)
event_status(id) track liveness yourself (see below)
set_event_mark() / by_marks() / $ALL_EVENTS plain Lua tables of voice IDs (see below)

The relative flag works like KSP's relative bit; immediate skips smoothing. Values are floating point in natural units — revisit the unit cheat sheet before porting any change_* call.

Event marks and $ALL_EVENTS become Lua tables

KSP needs marks and $ALL_EVENTS because it has no data structures for IDs. In Lua, a table of voice IDs is the idiom — and it replaces get_event_ids(), event_status(), and marks at once:

KSP:

on note
if ($EVENT_NOTE mod 12 = 0)
set_event_mark($EVENT_ID, $MARK_1)
end if
end on
on controller
if ($CC_NUM = 1)
change_tune(by_marks($MARK_1), %CC[1] * 1000, 0)
end if
end on

uvi-script:

local cNotes = {} -- voiceId -> true
function onNote(e)
local id = postEvent(e)
if e.note % 12 == 0 then
cNotes[id] = true
end
end
function onRelease(e)
cNotes[e.id] = nil -- e.id is the voice being released
end
function onController(e)
postEvent(e) -- keep forwarding CCs (MIDI learn, …)
if e.controller == 1 then
for id in pairs(cNotes) do
changeTune(id, e.value * 0.01) -- e.value cents, like the KSP original
end
end
end
void onController(table e)
event callback that will receive all incoming control-change events when defined.
function changeTune(voiceId, shift, relative, immediate)
change the tuning of specific voice in (fractionnal) semitones.
Definition api.lua:488

A voice ID becomes invalid once the voice ends; pruning the table in onRelease (or after a fadeout(id, ms, true)) is the equivalent of checking event_status().

ignore_event()'s companion problem — losing volume/tune/pan set by earlier scripts when re-creating a note — mostly disappears: the event table carries e.vol, e.pan and e.tune, and you can hand them straight to playNote(note, vel, dur, layer, channel, input, vol, pan, tune).

MIDI input and output

Reading controllers was covered in the callbacks table: onController(e) for CCs, onPitchBend(e) for bend, onAfterTouch(e) for channel pressure, plus getCC(num) to poll the last value of any CC (the CC[] array equivalent).

Generating MIDI (set_controller() and multi-script set_midi()) maps to the MIDI generation group:

KSP uvi-script
set_controller(num, val) controlChange(num, val)
set_controller($VCC_PITCH_BEND, -8192…8191) pitchBend(bend) with bend in [-1;1]
set_controller($VCC_MONO_AT, val) afterTouch(val)
set_poly_at(note, val) polyAfterTouch(val, note)
ignore_controller define the callback and do not forward
set_midi(chan, cmd, b1, b2) (multi script) postMidiEvent(event) with a MidiEvent

Kontakt's multi script (on midi_in) exists because instrument scripts sit behind the note mapping. In Falcon the same script API runs at every level of the event chain, and the onEvent(e) master callback sees every incoming event with its raw type (Event.NoteOn, Event.ControlChange, …) — so a "multi script" is just a script that defines onEvent. See MIDI Event Generation.

Engine parameters, groups and purge

KSP addresses the engine positionally — parameter constant, group index, slot index, generic flag — with values normalized to 0 … 1000000. uvi-script exposes the engine as an object tree (SynthPartProgramLayerKeygroupOscillator) where every node answers setParameter / getParameter with real-world values:

KSP:

{ cutoff of the filter in slot 1 of group 0, normalized }
set_engine_par($ENGINE_PAR_CUTOFF, 500000, 0, 0, -1)
declare @cutoff_str
@cutoff_str := get_engine_par_disp($ENGINE_PAR_CUTOFF, 0, 0, -1)

uvi-script:

local filter = Program.layers[1].keygroups[1].inserts[1]
filter:setParameter("Freq", 5000) -- 5000 Hz, no normalization
print(filter:getParameter("Freq"))
A Patch that represents a monotimbral instrument.
Definition Engine.cpp:257
table layers
Layer list for this Program (1-indexed, use #Program.layers to get the count)
Definition Engine.cpp:266

There is no get_engine_par_disp() because values never leave their natural unit; UI formatting is handled by widget units. Parameter names are strings — the full catalog per element type is on the Elements & Parameters page, and hasParameter() guards against typos. Effects are found by position (.inserts[i], .sends[i], .auxs[i]) and modulators by display name (.modulations["Amp. Env"]).

Kontakt groups vs Layers, Keygroups and Oscillators

The two engines slice an instrument differently, and this is the main mental-model shift on the engine side. In Kontakt, the group is one flat container that does everything at once: it holds the zones (sample mappings), selects the playback source mode, owns an amp envelope and a group-insert FX chain, and carries the start options (velocity ranges, round robins, cycle rules) that decide whether it plays the next note.

Falcon splits those responsibilities across three nested levels:

  • A Keygroup is the mapping unit: key/velocity ranges with crossfades (LowKey/HighKey, LowVelocity/HighVelocity, the …Fade parameters), plus the trigger logic that Kontakt puts in group start options — TriggerMode, TriggerRule, LatchTrigger, ExclusiveGroup. It contains one or more oscillators and can have its own FX and modulators (the per-voice level: filters and envelopes usually live here).
  • An Oscillator is the sound source inside a keygroup: a sample player for a Kontakt zone's sample, but possibly also slice, stretch, granular, wavetable, FM or analog oscillators — the choice Kontakt makes globally per group with the source-module mode is made per oscillator here.
  • A Layer is the musical grouping: a named set of keygroups with performance behaviour Kontakt keeps at instrument level or fakes in groups — PlayMode (poly/mono/legato), portamento, polyphony, velocity curve — plus its own FX, modulators and event processors.

Rough translation heuristics:

Kontakt Falcon
instrument Program
group (articulation, mic position, velocity layer, RR set) Layer, or a set of keygroups
zone (one sample, key/vel range) Keygroup + its sample Oscillator
source module mode (Sampler / DFD / Time Machine / Tone Machine) the oscillator type (sample player, stretch, granular, …)
group amp envelope the keygroup's modulations["Amp. Env"]
group inserts keygroup.inserts or layer.inserts
instrument inserts / sends Program.inserts / .sends / .auxs
group start options, voice groups keygroup parameters: TriggerMode, TriggerRule, ExclusiveGroup, … (see Elements & parameters)

One upgrade worth noticing: Kontakt group start options are static patch configuration, while their Falcon counterparts are ordinary parameters — a script can rewire them live with setParameter().

Command mapping:

KSP uvi-script
get_group_idx("name") findLayer(name), or iterate Program.layers / .keygroups
group_name(idx) the tree is navigated by reference, not index — keep references
allow_group() / disallow_group() route notes instead: playNote(…, layer) takes a layer index (or a table of them), and e.layer routes a forwarded event
$NUM_GROUPS #Program.layers, #layer.keygroups
purge_group(idx, mode) purge(element, cb) / unpurge(element, cb) on any tree node
get_purge_state() oscillator.purged
set_voice_limit() / get_voice_limit() polyphony is a Program/Layer parameter — see Elements & parameters

Group start options (velocity ranges, round robins, cycle conditions) are patch configuration in Falcon (keygroup mapping, triggers and rules), not script territory: scripts that used groups to fake round-robins can either rely on the mapping, or script it directly with playNote's layer / oscIndex arguments.

FX chains, sends and buses

Kontakt routes audio through fixed slots addressed positionally: a fixed set of insert slots per group, instrument insert / send / main slots, and up to 16 instrument buses — all reached through the same set_engine_par(par, value, group, slot, generic) call, with generic switching between $NI_INSERT_BUS, $NI_SEND_BUS, $NI_MAIN_BUS and $NI_BUS_OFFSET + n.

Falcon replaces the slot/constant arithmetic with collections that exist on every node of the tree:

  • .inserts[i] — the node's effect chain, in order. Any length, no eight-slot cap.
  • .sends[i] — send taps (BusRouter elements); the send amount is a parameter on the tap itself: keygroup.sends[2]:setParameter("Gain", 0.5).
  • .auxs[i] — auxiliary buses (on Synth, Part and Program), and each aux owns its own insert chain: Program.auxs[1].inserts[2]:setParameter("Volume", -6).
  • element.output — every element has an output-routing property, which covers what Kontakt's group→bus assignment and $ENGINE_PAR_OUTPUT_CHANNEL do.

So a KSP (group, slot, generic) triple becomes a path expression: "send slot 2 of the instrument" is Program.auxs[2], "insert slot 0 of group 3" is a keygroup.inserts[1] (mind the 1-based indices). Kontakt's 16-bus section maps onto layers acting as submixes (each with its own inserts) plus output routing, with aux buses for shared effects.

One real gap to plan around: since Kontakt 5.5, KSP can hot-swap the effect loaded in a slot ($ENGINE_PAR_EFFECT_TYPE / $ENGINE_PAR_EFFECT_SUBTYPE). Falcon scripts cannot insert, remove or replace effects — the tree is fixed by the patch. Port that pattern by loading every effect variant in the patch and scripting their Bypass parameter (every FX element has one) or mix instead:

local chorus, flanger = Program.inserts[1], Program.inserts[2]
FxSelect = Menu{"FX", {"Chorus", "Flanger"}}
FxSelect.changed = function(self)
chorus:setParameter("Bypass", self.value ~= 1)
flanger:setParameter("Bypass", self.value ~= 2)
end
FxSelect:changed()
Menu widget.
Definition ui.cpp:1139
table inserts
all InsertEffect for this node
Definition Engine.cpp:262
function changed
callback function used by child widgets to be notified of changes
Definition ui.cpp:875

To keep this tidy when there are many variants, use an Effect Rack: a single insert that hosts several parallel effect chains. Build one chain per Kontakt effect type in the patch, then have the script switch chains (each chain has Bypass and Gain parameters) — a one-slot, switchable multi-effect that behaves like Kontakt's swappable slot without touching the rest of the chain.

One thing KSP does not have: the script itself can be a modulation source. sendScriptModulation() feeds a value (optionally per-voice) that the patch can wire to any modulation target — often a cleaner design than driving parameters imperatively. See Voice Manipulation.

User interface

Widgets are objects, created at the top level of the script (the on init equivalent). The constructor replaces declare ui_*, properties replace set_control_par(), and the changed callback replaces on ui_control:

KSP:

on init
declare ui_knob $Rate (50, 1000, 1)
set_knob_unit($Rate, $KNOB_UNIT_MS)
set_knob_defval($Rate, 250)
set_control_par(get_ui_id($Rate), $CONTROL_PAR_POS_X, 10)
make_persistent($Rate)
end on
on ui_control ($Rate)
message("rate: " & $Rate)
end on

uvi-script:

Rate = Knob{"Rate", 250, 50, 1000, unit = Unit.MilliSeconds}
Rate.x = 10
Rate.changed = function(self)
print("rate:", self.value)
end
Rate:changed() -- idiom: run once at load to initialise dependent state
Knob widget.
Definition ui.cpp:1525
Predefined unit types.
Definition ui.cpp:691
@ MilliSeconds
display ms symbol.
Definition ui.cpp:714
int x
x position in pixels
Definition ui.cpp:859
function run(fun,...)
launch a function in a separate parallel execution thread.
Definition api.lua:1227

Ranges are real numbers (pass true as the fifth argument for integer-stepped controls), display formatting comes from unit = Unit.X instead of set_knob_unit, response curves from mapper = Mapper.X. make_persistent is gone: widget state persists automatically (see next section).

The widget model itself changes

Beyond the name mapping, five behavioural differences reshape how UI code is written. Porting widget by widget without knowing them produces code that compiles but behaves differently.

1. Values are real numbers — the display ratio disappears. A KSP knob holds an integer, and fractional values are faked with the display ratio: declare ui_knob $Freq (0, 1000, 10) shows 0.0 … 100.0 while $Freq is 0 … 1000, and every use of $Freq in the code carries the ×10 factor. In uvi-script value is a float: declare Knob("Freq", 25, 0, 100) and use Freq.value directly. When porting, divide the range and every downstream use by the ratio once — leaving a stray ×10 in a formula is the UI cousin of the µs→ms bug.

2. Response curves and formatting are declarative. KSP knobs are linear; exponential behaviour means lookup tables or pow math in the callback, and unit display means set_knob_unit plus set_knob_label string tricks. Here both are constructor options: mapper = Mapper.Exponential (or Mapper.Cubic when the range includes 0) and unit = Unit.Hertz — the unit also handles parsing when the user types a value. Delete the curve math from the callback when you port; do not stack a hand-rolled curve on top of a mapper.

3. Programmatic changes can fire the callback. In KSP, $Knob := x never triggers on ui_control — hence the idiom of wrapping callback logic in a function and call-ing it after every assignment and after read_persistent_var. Here it is the other way around: w:setValue(v) runs w.changed by default (pass false as the second argument to suppress it), and persistent widgets call their changed on preset reload automatically. The only boilerplate left is one w:changed() at the end of the script to initialise dependent state — the equivalent of KSP's manual function call in on init.

4. Layout is free pixels plus containers, not a fixed grid — and the window size cap is gone. Kontakt's UI is a 6 × 16 grid (move_control), grid and pixel parameters cannot be mixed, and even in pixel mode the performance view is hard-capped (set_ui_width_px 633 … 1000, set_ui_height_px 50 … 750). Here setSize(w, h) takes whatever you ask — a script UI can fill the whole Falcon window — and every widget has free .x / .y / .width / .height, and moveControl() remains only as a grid-style convenience. The KSP trick move_control($w, 0, 0) to hide a widget becomes w.visible = false. What Kontakt has no counterpart for: Panel containers auto-flow their children (panel:Knob(...) places itself), and Viewport gives you a scrollable UI. Build the layout with panels first — see the UI guide for the sizing rules.

5. Look and feel is per-widget, not skin-based. Kontakt customisation goes through the resource container and $CONTROL_PAR_PICTURE skins. Here every widget exposes colour properties (named colours or "#AARRGGBB" strings), Image / SVG widgets, TrueType fonts on labels, and setStripImage for film-strip knobs with automatic @2x Retina variants — no resource container step.

Also note the button nuance: KSP splits ui_button (fires on mouse-up, cannot be automated) from ui_switch (mouse-down, automatable). Here Button is the momentary action and OnOffButton the state, and automation is orthogonal — any value widget (knob, slider, button, menu…) becomes host-automatable with w.exported = true.

Widget mapping

KSP uvi-script
ui_knob Knob
ui_slider Slider
ui_button Button (momentary) / MultiStateButton
ui_switch OnOffButton
ui_menu + add_menu_item() Menu — pass the item list at construction, or addItem()
ui_label + set_text() Labellabel.text = "…"
ui_text_edit Label with editable = true
ui_value_edit NumBox
ui_table Table
ui_xy XY
ui_waveform WaveView
ui_wavetable WaveView (closest; no wavetable-position display)
ui_level_meter + attach_level_meter() AudioMeter(name, element, …) — construction attaches it to any tree node's bus (Program, a layer, an insert…), input or output side, with per-dB-band colours, scale display, mono sum and strip images
ui_file_selector + fs_* FileSelector, or browseForFile for a dialog
ui_mouse_area DnDArea (drag & drop)
ui_panel Panel, plus Viewport for scrolling
Image, SVG

Common UI commands:

KSP uvi-script
set_control_par(get_ui_id($w), $CONTROL_PAR_…, v) direct properties: w.x, w.y, w.width, w.height, w.tooltip, colours, …
get_ui_id() not needed — widgets are references
move_control($w, col, row) moveControl(w, col, row)
move_control_px() set w.x / w.y
set_ui_height_px() / set_ui_width_px() setSize(w, h) / setHeight(h)
make_perfview makePerformanceView()
set_key_color(note, $KEY_COLOR_RED) setKeyColour(note, "red") — named colours or "#RRGGBB" / "#AARRGGBB" strings
— reset resetKeyColour(note)
set_keyrange() / remove_keyrange() special colours mark the active range: setKeyColour(note, "#00FFFFFF") = valid key, "#00000000" = invalid
instrument wallpaper / set_skin_offset() setBackground(path), per-widget setStripImage for film-strip knobs
set_control_help() w.tooltip = "…"
hide_part(…, $HIDE_WHOLE_CONTROL) w.visible = false
hide_part() $HIDE_PART_TITLE / $HIDE_PART_VALUE w.showLabel; w.showValue on knobs

Two KSP patterns deserve a special mention:

  • Host automation: any value widget becomes a host-automatable parameter with w.exported = true — no $CONTROL_PAR_AUTOMATION_NAME dance.
  • on ui_update mirroring: the KSP idiom of polling get_engine_par() from on ui_update to keep a knob in sync with the engine is replaced by parameter-bound widgets: ParamKnob, ParamSlider, ParamOnOffButton, ParamMenu, ParamNumBox and ParameterValue bind to an Element parameter and stay in sync in both directions (host automation, modulation, preset loads), inheriting range, default and unit from the parameter:
local fx = Program.inserts[1]
Drive = ParamKnob(fx, "DriveAmount") -- two-way binding, no callback needed
A knob bound to an existing Element parameter.
Definition ui.cpp:1591

See User Interface for layout rules (the 120-px grid), Retina image support and the full widget reference.

Coming from Komplete UI (Kontakt 8)

Kontakt 8 introduced Komplete UI: the interface lives in separate .kscript modules (declarative components composed from primitives like Text, Rectangle, VStack/HStack/ZStack), bound to the KSP layer through expose_controls and control IDs, with its own toolchain (resource container komplete_scripts folder, VS Code extension, developer mode). Its main reason to exist is that classic Kontakt UI is fixed-resolution bitmap skins — Komplete UI is the HiDPI/vector layer bolted on top.

That motivation does not transfer: uvi-script UI is HiDPI-capable natively (@2x Retina image variants everywhere an image is accepted, plus the SVG widget), in the same single Lua script as the logic — no second language, no compile step, no ID indirection. What carries over, and what does not:

Has an equivalent:

  • TextLabel; RectanglePanel (backgroundColour, backgroundImage) or Image.
  • SVG vector assets → the SVG widget.
  • Component composition (building a custom control from primitives) → plain Lua: write a factory function that creates and configures a Panel with children and returns it.
  • The KSP-control binding (expose_controls + kscript-side ID lookup) → gone entirely; the widget object is the control, changed is the binding.
  • The "animation slider" trick (a KSP ui_slider the kscript layer observes) → drive widget properties (.x, .alpha, value, colours) directly from a spawn()-ed loop.

No equivalent:

  • Responsive stack layout (VStack/HStack/ZStack reflowing on resize) — layout here is fixed-size (setSize) with pixel positions and Panel auto-flow; there is no resize-driven reflow.
  • Canvas drawing (Bézier paths drawn at runtime) — closest are pre-rendered assets: SVG, Image, film-strip knobs.
  • A declarative animation/timeline system — animation is manual (spawn + wait() mutating properties), fine for fades and indicators, not for complex motion design. Level meters don't need any of this: AudioMeter listens to its bus by itself.

Persistence and snapshots

KSP persistence is opt-in per variable; uvi-script persistence is automatic for widgets and callback-based for everything else:

KSP uvi-script
make_persistent($widget) automatic — every widget's state is saved with the preset (including all Table cells)
opt out w.persistent = false
make_persistent(data) (non-UI data) return it from onSave(), restore in onLoad(data)
read_persistent_var() / on persistence_changed onLoad(data) runs after load with the saved table
set_snapshot_type() not needed — presets/programs are handled by Falcon and the host
make_instr_persistent() no direct equivalent (no snapshot/instrument split)
recorded = {}
function onSave()
return { recorded = recorded } -- any JSON-serialisable table
end
function onLoad(data)
recorded = data.recorded
end
table onSave()
callback that is called when saving the ScriptProcessor state.
void onLoad(table data)
callback that is called when restoring a ScriptProcessor state if custom data has been saved.

Files, arrays and async operations

KSP's global on async_complete callback — with its $NI_ASYNC_ID bookkeeping and wait_async() — becomes a completion callback passed to each async function. The callback receives a task object with a success flag plus an operation-specific result property (result for browseForFile, midi for loadMidi / createMidiFile; the loadData / loadTextData callbacks receive the loaded data directly), so the ID-matching boilerplate disappears:

KSP:

on init
declare $load_id
end on
on ui_control ($load)
$load_id := load_midi_file(<midi-file-path>)
while ($load_id # -1)
wait(1)
end while
message("MIDI file loaded!")
end on
on async_complete
if ($NI_ASYNC_ID = $load_id)
$load_id := -1
end if
end on

uvi-script:

loadMidi("/path/to/file.mid", function(task)
if task.success then
print("MIDI file loaded!")
end
end)
function loadMidi(path, callback)
load a midi file asynchronously
Definition api.lua:260
KSP uvi-script
save_array() / load_array() / _str saveData(table, path, cb) / loadData(path, cb) — whole nested tables as JSON
raw text files saveTextData / loadTextData
asking the user for a file browseForFile(mode, title, init, patterns, cb)
get_folder($GET_FOLDER_…) getLocation(loc)"Script" (the script's folder), "ProgramPath", "OriginalProgramPath", plus "Home", "Documents", "Desktop", "Music", "Temp"
save_midi_file() / the mf_* command set loadMidi, createMidiFile, saveMidi and the MidiSequence / MidiEvent classes — events as objects instead of mf_get_next() iteration
load_ir_sample() loadImpulse(reverb, path, cb)
saveState / loadState (whole script state to a file)

Sample access — KSP's zone commands — maps to oscillators in the tree: set_sample() becomes loadSample(oscillator, path, cb), and set_zone_par() / set_loop_par() playback settings (start, end, loop points) become setPlaybackOptions(...) plus oscillator parameters (see Elements & parameters). Slices are triggered through playNote's slice argument. There is no scriptable zone-mapping editor — key/velocity mapping belongs to the patch (see gaps below).

See Asynchronous Operations for the complete async model.

What has no direct equivalent

Honesty section. If your script leans on one of these, expect redesign, not translation:

  • Music Information Retrieval (detect_pitch(), detect_key(), detect_drum_type(), …) — no equivalent analysis API. Sample metadata is available, though: Oscillator's sampleInfo exposes tempo, duration, sample rate and loop points (which covers the detect_tempo() use case), and slice oscillators add looplabInfo / getSliceInfo() for per-slice timing.
  • PGS and script chaining (pgs_set_key_val(), on pgs_changed, set_event_par($EVENT_PAR_0…3)) — there is no cross-script message bus. In practice the pattern exists because KSP splits work across 5 slots; a single uvi-script (real functions, modules, tables) usually absorbs the whole chain. For script → engine communication, script modulations cover most remaining cases.
  • Zone editing / user zones (set_zone_par($ZONE_PAR_HIGH_KEY), set_num_user_zones(), …) — key/velocity mapping is patch data, not script data. Scripts load samples into existing oscillators (loadSample) and configure playback (setPlaybackOptions), but do not build mappings. Generation of mappings is done in Falcon's editor (or by generating patch files offline).
  • on rpn / on nrpn — there is no dedicated callback, but RPN/NRPN are just standard CC sequences, so decode them in onController. These controller numbers are consumed by default and never reach the script: opt in first with enable_nrpn_as_cc(true) / enable_data_entry_as_cc(true) (and enable_rpn_as_cc(true) for RPN) — see the MIDI guide. NRPN uses CC 99/98 (parameter MSB/LSB) then CC 6/38 (data MSB/LSB); RPN is identical with CC 101/100:
    enable_nrpn_as_cc(true) -- receive CC 98/99
    enable_data_entry_as_cc(true) -- receive CC 6/38
    local nrpnParam, nrpnValue = 0, 0
    function onController(e)
    local c, v = e.controller, e.value
    if c == 99 then nrpnParam = v * 128 -- param MSB
    elseif c == 98 then nrpnParam = nrpnParam + v -- param LSB
    elseif c == 6 then nrpnValue = v * 128 -- data MSB
    elseif c == 38 then -- data LSB completes it
    nrpnValue = nrpnValue + v
    onNrpn(nrpnParam, nrpnValue) -- your handler
    else postEvent(e) end
    end
  • MIDI 2.0 per-note controllers (on note_controller, set_note_controller(), set_rpn() / set_nrpn()) — not exposed. The underlying need (per-note expression) is served directly by per-voice control: changeTune, changeVolume, changePan and per-voice sendScriptModulation on any voice ID at any time — the restriction that made per-note controllers necessary does not exist here.
  • stop_wait() — no external interruption of a suspended coroutine. Structure loops so they re-check their condition after every wait/waitBeat (a shared flag variable does the job of stop_wait(id, 1)).
  • Keyboard metadata (set_key_name(), set_key_type()) — key names and types are not exposed; label keyswitches in your UI instead. Key ranges are covered, though: see the set_keyrange() row in the UI section.
  • redirect_output() — no per-event output-bus routing from the script; output routing is patch configuration in Falcon.
  • Komplete Kontrol / Creator Tools layer (GUI Designer performance views, get_font_id(), NKS hardware pages) — no NKS hardware-UI layer. Rebuild the panel with UI widgets, mark host-facing controls with w.exported = true, and call makePerformanceView. For the Kontakt 8 Komplete UI framework (load_komplete_ui(), expose_controls), see the UI section for what carries over and what does not.
  • System scripts (pedal and release-trigger preprocessor overrides: NO_SYS_SCRIPT_PEDAL, NO_SYS_SCRIPT_RLS_TRIG, …) — Falcon handles the sustain pedal natively and release triggers are a keygroup trigger setting in the patch; for custom pedal logic, read CC 64 from onController / getCC.
  • watch_var() debugging — use print() and the script console.
  • Resource container — keep assets next to the script or the instrument and locate them with getLocation("Script") / getLocation("ProgramPath").

A complete port, side by side

A small but representative script: a note repeater with a persistent rate knob. It exercises most of the traps at once — event forwarding, polyphonic state, µs → ms, persistence, and UI declaration.

KSP:

on init
declare ui_knob $Rate (50, 1000, 1)
set_knob_unit($Rate, $KNOB_UNIT_MS)
set_knob_defval($Rate, 250)
make_persistent($Rate)
make_perfview
end on
on note
ignore_event($EVENT_ID)
while ($NOTE_HELD = 1)
play_note($EVENT_NOTE, $EVENT_VELOCITY, 0, $Rate * 900)
wait($Rate * 1000)
end while
end on

uvi-script:

Rate = Knob{"Rate", 250, 50, 1000, unit = Unit.MilliSeconds}
function onNote(e)
while isNoteHeld() do
playNote(e.note, e.velocity, Rate.value * 0.9) -- ms, not µs
wait(Rate.value)
end
end
function onRelease(e) end -- note-on was swallowed: swallow the note-off too
setSize(120, 60)
void makePerformanceView()
make this script User Interface visible in performance view.
Definition ui.lua:107
void setSize(number w, number h)
set the script UI dimensions explicitly.

What changed, line by line:

  • declare ui_knob + set_knob_unit + set_knob_defval collapse into one Knob constructor; make_persistent is implicit.
  • ignore_event() is implicit too: defining onNote without postEvent(e) swallows the note — but that makes the empty onRelease mandatory.
  • No polyphonic declaration needed: each onNote runs in its own coroutine, so the loop state is per-note automatically.
  • $Rate * 1000 (ms → µs) disappears — wait() already takes milliseconds.
  • $NOTE_HELDisNoteHeld().

Where to go next

Kontakt, KSP and Komplete are trademarks of Native Instruments GmbH. UVI is not affiliated with, nor endorsed by, Native Instruments. They are referenced here solely to assist developers migrating their scripts.