How to Set Up Adaptive Releases¶
Configure release samples that match the sustain amplitude at the moment of key release, so releases blend seamlessly with the decaying sustain.
Prerequisites¶
- A TACT product with sustain and release sample groups already mapped in Kontakt
- Python 3.13+ with
numpy,librosa,soundfile,scipy, andfastdtwinstalled - Understanding of flows and TACT
Steps¶
1. Run CreateReleaseArray.py on sustain samples¶
The CreateReleaseArray.py script analyzes your sustain samples to build a volume-over-time profile for each note, velocity layer, and round robin. This data is stored in two NKA files that the runtime uses to match release playback position to sustain amplitude.
from CreateReleaseArray import create_release_array
create_release_array(
shorts_folder=[
"/path/to/Staccato",
"/path/to/Spiccato",
"/path/to/Releases"
],
instrument="Violins1",
note_range=[24, 98], { MIDI note range across all sections }
max_vel=5, { maximum velocity layers }
max_rr=6, { maximum round robins }
end_times={ { per-artic analysis window in seconds }
"Staccato": 1.5,
"Spiccato": 1.8,
"Releases": 1.0
},
artic_tokens=[ { filename token positions: (note, vel, rr) }
(3, 4, 5),
(3, 4, 5),
(5, 4, -1) { -1 means no RR token in filename }
],
window_length=0.02, { analysis window in seconds }
window_smooth=0.05, { smoothing window in seconds }
lufs_match={"Staccato": True, "Spiccato": True, "Releases": True},
lufs_preprocess={"Staccato": False, "Spiccato": False, "Releases": False}
)
This produces two NKA files:
- vol_data_violins1.nka -- flattened volume profile data (millidB values per time window)
- _vol_index_violins1.nka -- index array mapping (artic, note, velocity, rr) to positions in vol_data
2. Load NKAs at init¶
In your product, declare the volume data arrays and load the NKAs. The dimensions must match the analysis parameters:
node MyProduct.Base.AdaptiveReleases:
cb Init:
define MY.NOTE_RANGE := (MY.VOL_DATA_MAX_NOTE - MY.VOL_DATA_MIN_NOTE) + 1
declare vol_index[MY.NUM_VOL_TIME_ARRAY_ENTRIES, MY.NOTE_RANGE, MY.VOL_DATA_MAX_VEL, MY.VOL_DATA_MAX_RR, 2]
declare vol_data[MY.VOL_DATA_ARRAY_SIZE]
cb Init:
load_array(_vol_index, 2)
load_array(vol_data, 2)
end nodeThe vol_index array stores start/end positions (the 2 in the last dimension) into vol_data for each combination of articulation, note, velocity layer, and round robin.
3. Create a release spawn node¶
At note-off, record the elapsed time, look up the sustain's current volume from the pre-analyzed data, and spawn a release voice:
node MyProduct.Spawn.Release:
cb Init:
{ Track the volume offset and note start time }
vo_field my.release.vol_offset = MY.RLS_STANDARD_VOLUME_TARGET if (my.release.vol_offset <= 1)
vo_field my.release.note_time = 0 if (my.release.note_time >= 0)
cb NoteOn:
{ Record when this note started playing }
Voice[self].my.release.note_time := ENGINE_UPTIME
ivls.pass()
cb NoteOff:
declare artic := Voice[self].tact.trigger_artic
declare note_length := ENGINE_UPTIME - Voice[self].my.release.note_time
{ Look up volume at current playback position }
declare artic_vol
declare rls_delay
my.rls_vol.fill_adaptive_rls_fields(self, artic_vol, rls_delay)
Voice[self].my.release.vol_offset := artic_vol
{ Spawn the release voice if volume is above threshold }
if Voice[self].my.release.vol_offset > MY.NO_RLS_VOL_THRESHOLD
declare rel_vo := ivls.new_formal_voice(self)
ivls.wait_ms(rls_delay)
ivls.reflow_voice(rel_vo, my.flows.play_release)
ivls.play.oneshot(rel_vo, 1000000)
end if
end node4. Match time to volume and apply offset¶
In the release playback node, use tky.rls_vol.match_time_to_vol() to find the playback position in the release sample that matches the sustain's volume, then apply a volume offset via Modbit:
node MyProduct.Play.Release:
cb NoteOn:
declare rel_artic := Voice[self].tact.release_artic
if rel_artic # -1
declare note := Voice[self].note
declare dyn_layer := tact.dynamics.get_vel_layer(thread, self, tact.dynamics.layer_type.GLOBAL)
declare rr := Voice[self].rr
{ Find the time position in the release sample that matches the sustain volume }
declare start_pos := my.rls_vol.match_time_to_vol( ...
rel_artic, Voice[self].my.release.vol_offset, note, dyn_layer, rr)
{ Apply the matched start position as a sample offset }
Voice[self].my.offset_override := start_pos * 1000
{ Calculate remaining volume difference and apply as a Modbit }
declare rls_vol_at_time := my.rls_vol.get_vol_at_time(rel_artic, start_pos, note, dyn_layer, rr)
declare vol_offset := Voice[self].my.release.vol_offset - rls_vol_at_time
declare modbit_vol := Modbit.local_modbit(ivls.mod_par.VOLUME, ivls.mod_type.ADD)
Modbit.add_to_voice(modbit_vol, self)
Modbit.set(modbit_vol, vol_offset)
ivls.play(self)
end if
ivls.pass()
end node5. Add to flow via Divert.PlayEvent routing¶
Route voices through the release spawn node before the standard play event. Use a Divert node to send sustain-type voices through the release path:
node MyProduct.Divert.PlayEvent:
cb Flows:
define FLOWS += my.playevent.flows.normal, ...
my.playevent.flows.withrelease, ...
my.flows.play_release
ivls.register_node(my.playevent.flows.normal, Stl.PlayEvent)
ivls.register_node(my.playevent.flows.withrelease, MyProduct.Spawn.Release)
ivls.register_node(my.playevent.flows.withrelease, Stl.PlayEvent)
ivls.register_node(my.flows.play_release, MyProduct.Play.Release)
cb NotePass:
{ Only add release tracking to sustain-type voices }
if Voice[self].my.voice_type = my.events.voice_type.STANDARD_ARTIC
ivls.reflow_voice(self, my.playevent.flows.withrelease)
else
ivls.reflow_voice(self, my.playevent.flows.normal)
end if
end nodeRuntime mechanism¶
The adaptive release system works in three stages:
- Elapsed time: When a note is released, the system calculates how long the sustain has been playing (
ENGINE_UPTIME - note_time) - Volume lookup: Using the pre-analyzed NKA data, it looks up the sustain sample's amplitude at the computed playback position via
match_time_to_vol(), which scans the vol_data array for the closest volume match - Modbit offset and release play: The difference between the sustain's current amplitude and the release sample's amplitude at the matched position is applied as a Modbit volume offset; the release sample is then played as a oneshot via
ivls.play.oneshot()with the matched sample offset
Verify¶
- Play a sustain note and release it at different times -- short holds should produce louder releases, long holds should produce quieter releases
- Check that the release volume blends smoothly with the sustain's tail
- Verify that very quiet sustains (below the threshold) skip the release sample entirely
Further reading¶
- Guide: TACT for the articulation system that provides release artic assignment
- Guide: Modbits for how
Modbit.local_modbitandModbit.add_to_voicework - Stl.PlayEvent module reference for the event playback template