Skip to content

Legato


When a singer moves from one pitch to another without re-articulating, the transition is smooth -- the voice glides or steps between notes without a new attack. Emulating this in a sampled instrument means detecting overlapping notes and connecting them so the new note inherits the sonic context of the old one, rather than starting fresh.

IVLS splits legato into two concerns: assignment (deciding which voices are connected) and playback (deciding what happens musically with that connection). The standard library handles assignment. Your product handles playback.

The Split Architecture


The assignment side lives in two standard library nodes:

Stl.MonoLegato tracks a per-thread voice stack. When a new note arrives while a previous note is still held, it links the two voices as a legato pair -- the old voice becomes the source, the new voice becomes the destination. This is pure bookkeeping: it records who is connected to whom, but does nothing audible.

Stl.LegatoSwitch is a stock, example implementation of a legato response. It does the simplest possible thing: releases the source voice and unlinks the legato pair. No crossfade, no transition — just an immediate switch from old note to new note.

This confirms the key design point: Stl.MonoLegato deliberately hangs the source voice. It's alive but ownerless — no longer attached to the key press, not in a runtime, no duration. Stl.LegatoSwitch is one possible response that cleans it up. But in a real instrument, you'll replace Stl.LegatoSwitch with your own legato playback node that handles the source voice musically — crossfading it, gliding from it, or layering a transition sample over it. (See Ownership for the full concept.)

In a basic flow, MonoLegato and LegatoSwitch work together:

ivls.register_node(my_flow, Stl.MonoLegato)
ivls.register_node(my_flow, Stl.LegatoSwitch)

In a production flow, you replace LegatoSwitch with your own node:

ivls.register_node(my_flow, Stl.MonoLegato)
ivls.register_node(my_flow, MyLegatoPlayback)

Your node reads the legato link, takes ownership of the source voice (e.g. by fading it out), and handles the musical transition. The source voice is your responsibility — if you don't handle it, it hangs.

Formal Voices and ivls.pass_formal()


Inside Stl.MonoLegato, the node uses ivls.pass_formal() instead of the regular ivls.pass(). A formal pass creates a new voice modulation context (VMC) for the destination voice, giving it independent modulation space. This is important for legato: the new note needs its own tuning, volume, and modulation state, separate from the source.

After calling ivls.pass_formal(), the node reads NodeEnv[nenv].sentVoice to get the newly created formal voice and sets up the legato destination link on it:

ivls.pass_formal()

declare _sent := NodeEnv[nenv].sentVoice
Voice[_sent].legato.vo_dest := _sent
Voice[_sent].legato.is_current := TRUE

You rarely call ivls.pass_formal() yourself unless building custom legato or portamento logic. But understanding that it exists -- and that it differs from ivls.pass() -- helps you read legato code.

The Legato Predicate


To check whether a voice has an active legato connection, use the legato.found() predicate:

if legato.found(self)
    { This voice has a legato source -- handle the transition }
else
    { Normal note-on, no legato }
end if

This is a define-macro that checks whether both vo_src and vo_dest are populated (not -1). It is the standard way to branch between legato and non-legato behavior in your playback nodes.

Per-Voice Legato State


The legato system stores four fields on every voice, registered through the Voice member extension system:

  • legato.vo_src -- the voice this one is transitioning from (-1 if none)
  • legato.vo_dest -- the voice this one is transitioning to (-1 if none)
  • legato.is_current -- whether this voice is the currently active end of the legato chain
  • legato.registered -- whether this voice has been registered as part of a legato pair

Helper functions let you query and manipulate these:

declare src := legato.get_src_vo(self)
declare dest := legato.get_dest_vo(self)
declare interval := legato.get_interval(self)  { semitones between src and dest }

The Clone-on-Release Pattern


Stl.MonoLegato implements a behavior common in mono-legato instruments: when you release the current note while still holding previous notes, it restores the most recent held note. Think of a monosynth -- release the top note and the previous note comes back.

In cb NoteOff, the node checks whether the released voice was the topmost in the buffer. If it was, and there are still held notes, it clones the previous voice and re-triggers it:

{ Inside Stl.MonoLegato's cb NoteOff }
if del_idx = mono.VoiceBuffer.count[thread]
    if del_idx > 0
        declare old_vo := mono.VoiceBuffer[thread, del_idx - 1]
        ivls.clone_voice(old_vo, nenv)
        ivls.release_voice(old_vo)
    end if
end if

ivls.clone_voice() creates a new voice that inherits all voice fields from the original, then sends it through the flow as if it were a new note-on. The old voice is released to prevent doubling.

Path Cancellation


The legato base node includes a cb PathCancellation handler that releases the source voice if the current voice's path is cancelled. This prevents orphaned voices from lingering when the system cleans up:

cb PathCancellation:
    if check_ref(Voice, legato.get_src_vo(self))
        ivls.release_voice(legato.get_src_vo(self))
    end if

Poly Legato


IVLS also includes Stl.PolyLegato for polyphonic legato -- connecting multiple simultaneously released and pressed notes. It uses a latency window to collect note events that arrive within a configurable time frame, then runs an assignment algorithm that pairs voices by minimizing the largest semitone interval across all pairings.

Poly legato is significantly more complex than mono legato and is beyond the scope of this chapter. The key idea: it exists, it is configurable via polyleg.latency, and it uses the same legato.found() predicate and voice fields as mono legato. Your playback code does not need to know which assignment strategy produced the link.


The assignment/playback split is the central design decision. Stl.MonoLegato handles the voice-tracking bookkeeping and deliberately hangs the source voice. Stl.LegatoSwitch is the simplest possible response — it releases the source and unlinks, giving you a hard switch. For real instruments, you replace LegatoSwitch with your own node that reads legato.found(), queries the source and destination voices, asserts ownership over the source voice's remaining lifetime (typically via a fade), and produces the musical transition.

If your node reads the legato link but doesn't take responsibility for the source voice — and you've removed LegatoSwitch — you'll get a hanging voice: alive, inaudible, consuming resources. Every legato implementation must close this ownership gap. See Ownership for the broader pattern.