Ownership¶
The most pernicious bug in virtual instrument development is the hung voice — a Kontakt event that plays indefinitely because nothing ever stops it. It's not a crash. It's not a glitch. It's a sustained tone that won't go away until the user reloads the instrument. In a live performance or a recording session, it's catastrophic.
Hung voices happen when a voice enters a state where nobody is responsible for ending it. The key was released, the pedal came up, the legato transition completed — but somewhere in the chain, the voice fell through a crack. No node released it. No fade killed it. No runtime cleaned it up. The Kontakt event persists, producing sound forever.
Ownership in IVLS is the discipline of ensuring that every voice, at every point in its lifetime, has a clear answer to the question: who is responsible for ending this voice?
The Default: Voice Tree Ownership¶
By default, every voice is owned by its parent in the voice tree. When the parent is released, all children with auto_release = TRUE are released with it. This is clean, automatic, and covers the vast majority of cases.
A key is pressed. A voice is created. The key is released. The voice is released. No hung voices.
The problems start when you need a voice to survive beyond its parent's natural lifetime. And in serious instrument development, you almost always do.
The Three Ownership Models¶
IVLS has three classical mechanisms for owning a voice. Every voice in your instrument is owned by exactly one of these at any given time:
1. Parent Voice (the default)¶
Every voice starts life owned by its parent in the voice tree. The parent's release cascades to all children with auto_release = TRUE. This is the default for most voices and requires no developer action — press a key, release a key, everything cleans up.
2. Runtime¶
A runtime adopts a voice, setting auto_release := FALSE so the voice survives its parent's release. The runtime is now responsible for ending the voice. When ivls.release_runtime() is called, all voices in the runtime are released.
This is what Stl.Pedals uses: when the sustain pedal goes down, a runtime is created. Voices are reparented into it on key release. When the pedal lifts, the runtime releases them all. Ownership transfers cleanly: parent → runtime → release.
3. Duration¶
A voice with a duration owns itself for a fixed time window. This is not just a data field — it's an elevated ownership mechanism built into the voice dispatch loop.
When a voice enters processing with Voice[self].duration > 0, the framework:
- Sets
auto_release := FALSE— detaching the voice from its parent's release chain - Sets
duration_limited := TRUEon the NodeEnv - Consumes and zeroes the duration field
- After the voice's node logic completes, writes the consumed duration back as a delay
- Waits for that delay via
ivls.wait() - Releases the voice when the wait completes
The voice is self-owning for the duration window. No parent release, no runtime, no developer NoteOff code — the framework handles the lifetime directly. This is what ivls.play.oneshot(vo, duration_us) sets up: it writes the duration and lets the dispatch loop manage the rest.
Duration ownership is the cleanest model for sounds with known lifetimes: release noises, one-shot FX, timed overlays like Smart Attack.
Ownership Gaps¶
The three models cover most cases, but certain musical behaviors require temporarily breaking ownership before re-establishing it. The most important example is legato.
When Stl.MonoLegato detects an overlapping note, it links the old voice (the source) to the new voice (the destination) — and deliberately abandons the source. The source's parent ownership no longer applies (MonoLegato has detached it from the key context). It's not in a runtime. It has no duration. It is alive, but ownerless.
This is intentional. The framework is handing the source voice to you, the developer. Your playback node must re-establish ownership — typically by fading it out, which creates a duration-limited modulation voice that sees the source through to silence.
The Hung Voice: An Ownership Gap¶
A hung voice is what happens when ownership is not transferred. The most common scenario:
1. Key pressed → voice created (key owns it)
2. MonoLegato detects overlap → source voice abandoned (ownerless)
3. Your playback node runs → you read legato.found(self)...
4. ...but you forget to do anything with the source voice
5. LegatoSwitch cleans up the link metadata
6. The source voice is still alive. Nobody will ever release it.Step 4 is the ownership gap. MonoLegato deliberately left the source ownerless because you are supposed to take responsibility for it. If you don't — if your playback node reads the legato link but doesn't fade out the source, or release it, or do anything that eventually ends its lifetime — you get a hung voice.
This is not a framework bug. It's a design contract: MonoLegato says "here is the source voice, it's your problem now." If you don't handle it, nobody will.
Closing Ownership Gaps¶
When a voice is ownerless, you must re-establish ownership using one of the three models:
Re-parent it (parent ownership):
ivls.release_voice(src)The simplest resolution. The voice is released immediately — its parent ownership ends it right now. Appropriate when you don't need a transition.
Give it a duration (duration ownership):
ivls.play.oneshot(src, 500000)The voice becomes self-owning for 500ms. The dispatch loop will release it when the duration expires. This is the cleanest model when you know how long the voice should live.
Fades use the same mechanism internally — fades.curve_out(src, 200, -0.5, TRUE) creates a modulation voice with a duration that drives the fade, and kill_on_end releases the source when the fade voice's duration expires.
Put it in a runtime (runtime ownership):
ivls.add_voice_to_runtime(my_runtime, src)The runtime adopts the voice. You'll release it later when the runtime is cleaned up. Less common for legato gaps, but appropriate when voices need to be grouped and released together.
Each of these re-establishes ownership using a classical model. The ownerless state is always temporary — a handoff between the framework abandoning the voice and you picking it up.
The Rule¶
Every voice in your instrument must have an unbroken chain of ownership from creation to release. At every point in its lifetime, one of the three models must apply:
- Parent → the default. The voice tree will release it.
- Runtime → a container holds it.
ivls.release_runtime()will release it. - Duration → a timer governs it. The dispatch loop will release it.
If none of these apply, the voice is ownerless, and you have an ownership gap.
When you build a new node that manipulates voice lifetimes — especially nodes that detach voices from their natural parents — audit the ownership chain. If you can trace every voice from creation to release with no gaps, you won't have hung voices.
If you can't, you've found the bug before your users do.