![]() |
uvi-script
Musical event scripting with Lua
|
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 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 note … end 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 |
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) |
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:
uvi-script:
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:
uvi-script:
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.
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.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.
This is the most important behavioural difference in the event model, and it inverts KSP's default:
ignore_event().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:
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:
uvi-script:
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:
uvi-script:
Global variables keep the role of KSP's regular variables: shared across all callbacks.
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:
uvi-script:
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.
$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.
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:
uvi-script:
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).
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.
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 (Synth → Part → Program → Layer → Keygroup → Oscillator) where every node answers setParameter / getParameter with real-world values:
KSP:
uvi-script:
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"]).
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:
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).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.
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:
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.
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:
uvi-script:
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).
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.
| 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() | Label — label.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:
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:See User Interface for layout rules (the 120-px grid), Retina image support and the full widget reference.
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:
Text → Label; Rectangle → Panel (backgroundColour, backgroundImage) or Image.expose_controls + kscript-side ID lookup) → gone entirely; the widget object is the control, changed is the binding.ui_slider the kscript layer observes) → drive widget properties (.x, .alpha, value, colours) directly from a spawn()-ed loop.No equivalent:
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.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.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) |
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:
uvi-script:
| 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.
Honesty section. If your script leans on one of these, expect redesign, not translation:
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_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.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: 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)).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.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.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.getLocation("ProgramPath").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:
uvi-script:
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.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_HELD → isNoteHeld().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.