Skip to main content

My KSP Fretless Bass Adventure — Where It All Went Wrong

· 5 min read
Rintaro Nakahodo
NLP Researcher · Engineer · Creator

After reading an article about FuruBass-Fretless, I thought: "I could build a Kontakt instrument myself."

Reader, I was not prepared.

KSP (Kontakt Script Processor) is far more opinionated than it looks. Implementing a single portamento effect ate half a day.


What I Was Trying to Build

The defining characteristic of a fretless bass is that notes flow into each other seamlessly — no frets means you can slide your finger up or down the string between pitches, and the pitch transitions continuously. I wanted to recreate this in Kontakt with three core behaviors:

  • Portamento: glide from the previous note's pitch to the current one
  • Slide-in: approach each note from a few semitones away before landing on the target pitch
  • Vibrato: the gentle, organic wobble that makes fretless bass sound alive

What is KSP?

KSP is the scripting language built into Native Instruments Kontakt. It's a proprietary language maintained by NI, with documentation that ranges from "helpful" to "cryptic" depending on what you're trying to do. The syntax is Pascal-like, but the execution model is callback-based:

on note       { ← called on every note-on event }
...
end on

on release { ← called on every note-off event }
...
end on

Variables are typed by prefix: $ for integers, % for integer arrays, @ for strings, ~ for reals. Mess up the prefix and KSP won't error — it just silently ignores the assignment. Bugs vanish without a trace.


Pitfall #1: change_tune Units Are Ambiguous

Portamento works through the change_tune function:

change_tune($EVENT_ID, $tune_value, $relative)

The question is: what does $tune_value actually mean?

The documentation exists but is terse. I ended up just plugging in values and listening:

{ Trial and error log }
change_tune($EVENT_ID, 100, 0) { ← barely moves }
change_tune($EVENT_ID, 10000, 0) { ← shifts slightly? }
change_tune($EVENT_ID, 1000, 0) { ← exactly 1 semitone up }

1000 = 1 semitone. It's "millisemitones" or something adjacent. The documentation says "semitone × 1000" but I was reading the wrong section for a while and kept putting in wildly wrong values.


Pitfall #2: wait() Inside on note Breaks Under Rapid Input

The portamento animation is a loop that gradually steps the pitch toward zero while calling wait() between each step:

on note
$offset := $porta_offset
while ($offset # 0)
$offset := $offset - $step_size
change_tune($EVENT_ID, $offset, 0)
wait(5000) { wait 5ms }
end while
end on

The problem: if you play fast, the previous note's loop keeps running while the new note starts. I ended up hearing weird detuned chords when playing eighth notes at any reasonable tempo. Thought I'd broken something fundamental.

The fix: keep $ANIM_STEPS low (I settled on 25) so the total loop time stays short. It's not perfect, but playable.


Pitfall #3: Slide-In and Portamento Interfere with Each Other

Slide-in approaches the target note from a few semitones away. The direction logic is straightforward:

if ($note > $prev_note)
$slide_offset := -($knob_slide_range * $SEMITONE) { approach from below }
else
$slide_offset := $knob_slide_range * $SEMITONE { approach from above }
end if

But when both slide-in and portamento are enabled, they collide. Slide-in ends with pitch at 0 (target). Then portamento says "start from the previous note's offset." If you use $relative = 0 (absolute), it wipes out whatever slide-in left behind. You need $relative = 1 (additive):

{ After slide-in, pitch is already at 0 — add the portamento offset on top }
if ($sw_slide = 1)
change_tune($EVENT_ID, $porta_offset, 1) { relative = 1 }
else
change_tune($EVENT_ID, $porta_offset, 0) { absolute }
end if

I spent about 30 minutes wondering why the pitch would randomly jump. The culprit was a single 0 vs 1.


Pitfall #4: Legato Detection Depends on Variable Update Order

I wanted portamento to be optional in "legato only" mode — only glide when the previous note is still held down.

declare %is_held[128]

on note
if ($menu_porta_mode = 1 and %is_held[$prev_note] = 1)
{ legato condition met — do portamento }
end if
%is_held[$NOTE] := 1 { ← must come AFTER the check }
end on

on release
%is_held[$EVENT_NOTE] := 0
end on

The trap: if you mark the new note as held before checking $prev_note, the logic is always comparing against a freshly-set value. Flip the order and you get either "portamento on every note" or "portamento never" — no middle ground.


Final Script Structure

After all the trial and error, the script settled into this shape:

on init     → UI knobs/switches, variable initialization
on note → slide-in → portamento → vibrato (in this order)
on release → clear the held flag
on controller → CC11 (Expression) and CC64 (Sustain Pedal)

The UI surface:

SectionControlDescription
PORTAMENTOON switchEnable/disable portamento
ModeAlways / Legato Only
TimeGlide duration (ms)
SLIDE INON switchEnable/disable slide-in
RangeSlide width in semitones
TimeSlide duration (ms)
VIBRATODepthVibrato depth
RateVibrato speed

How It Sounds

With Portamento Time at 150ms and Slide Range at 4 semitones, the legato lines feel remarkably fretless-like. The slide-in gives each note a sense of physical weight — the pitch arrives rather than snapping into place.

Things still to fix:

  • Portamento time synced to BPM (e.g., glide for exactly one 16th note)
  • Vibrato sometimes persists into the next note on fast passages
  • Behavior on repeated same-pitch notes needs testing with real samples

KSP has a way of making things look like they're working when they're actually quietly broken. The key discipline: always verify variable scope and wait() timing before assuming the logic is right.


The full script is in the kontakt_test repository on GitHub (coming soon).

Live with a Smile!