Skip to content

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, and fastdtw installed
  • 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 node

The 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 node

4. 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 node

5. 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 node

Runtime mechanism

The adaptive release system works in three stages:

  1. Elapsed time: When a note is released, the system calculates how long the sustain has been playing (ENGINE_UPTIME - note_time)
  2. 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
  3. 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

  1. Play a sustain note and release it at different times -- short holds should produce louder releases, long holds should produce quieter releases
  2. Check that the release volume blends smoothly with the sustain's tail
  3. 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_modbit and Modbit.add_to_voice work
  • Stl.PlayEvent module reference for the event playback template