diff --git a/README.md b/README.md
index 083bb8d..7b35973 100644
--- a/README.md
+++ b/README.md
@@ -329,15 +329,43 @@ A Clip Slot represents a container for a clip. It is used to create and delete c
Documentation: Clip Slot API
-| Address | Query params | Response params | Description |
-|:------------------------------------|:---------------------------------------------------------------|:-----------------------------------------|:------------------------------------------------|
-| /live/clip_slot/fire | track_index, clip_index | | Fire play/pause of the specified clip slot |
-| /live/clip_slot/create_clip | track_index, clip_index, length | | Create a clip in the slot |
-| /live/clip_slot/delete_clip | track_index, clip_index | | Delete the clip in the slot |
-| /live/clip_slot/get/has_clip | track_index, clip_index | track_index, clip_index, has_clip | Query whether the slot has a clip |
-| /live/clip_slot/get/has_stop_button | track_index, clip_index | track_index, clip_index, has_stop_button | Query whether the slot has a stop button |
-| /live/clip_slot/set/has_stop_button | track_index, clip_index, has_stop_button | | Add or remove stop button (1=on, 0=off) |
-| /live/clip_slot/duplicate_clip_to | track_index, clip_index, target_track_index, target_clip_index | | Duplicate the clip to an empty target clip slot |
+| Address | Query params | Response params | Description |
+| -------------------------------------------------- | ---------------------------------------------------------------- | ----------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
+| /live/clip_slot/fire | track_index, clip_index | | Fire play/pause of the specified clip slot |
+| /live/clip_slot/stop | track_index, clip_index | | Stops playling/recording the specified clip slot |
+| /live/clip_slot/create/midi_clip | track_index, clip_index, length | | Create a MIDI clip in the slot |
+| /live/clip_slot/create/audio_clip | track_index, clip_index, file_path | | Create an audio clip from file in the slot |
+| /live/clip_slot/delete/clip | track_index, clip_index | | Delete the clip in the slot |
+| /live/clip_slot/duplicate_to | track_index, clip_index, target_track_index, target_clip_index | | Duplicate the clip to a target clip slot |
+| /live/clip_slot/set/fire_button | track_index, clip_index, fire_button_state | | Set fire button state directly (1=on, 0=off) |
+| /live/clip_slot/get/color | track_index, clip_index | track_index, clip_index, color | Get clip slot color; Group Track slots only |
+| /live/clip_slot/start_listen/color | track_index, clip_index | track_index, clip_index, color | Listen for slot color changes; Group Track slots only |
+| /live/clip_slot/stop_listen/color | track_index, clip_index | | Stop listening for slot color changes; Group Track slots only |
+| /live/clip_slot/get/color_index | track_index, clip_index | track_index, clip_index, color_index | Get clip slot color index (0-69); Group Track slots only |
+| /live/clip_slot/start_listen/color_index | track_index, clip_index | track_index, clip_index, color_index | Listen for slot color index changes; Group Track slots only |
+| /live/clip_slot/stop_listen/color_index | track_index, clip_index | | Stop listening for slot color index changes; Group Track slots only |
+| /live/clip_slot/get/controls_other_clips | track_index, clip_index | track_index, clip_index, controls_other_clips | Get whether slot controls other clips; Group Track slots only |
+| /live/clip_slot/start_listen/controls_other_clips | track_index, clip_index | track_index, clip_index, controls_other_clips | Listen for controls_other_clips changes; Group Track slots only |
+| /live/clip_slot/stop_listen/controls_other_clips | track_index, clip_index | | Stop listening for controls_other_clips changes; Group Track slots only |
+| /live/clip_slot/get/has_clip | track_index, clip_index | track_index, clip_index, has_clip | Query whether the slot has a clip |
+| /live/clip_slot/start_listen/has_clip | track_index, clip_index | track_index, clip_index, has_clip | Listen for has_clip changes |
+| /live/clip_slot/stop_listen/has_clip | track_index, clip_index | | Stop listening for has_clip changes |
+| /live/clip_slot/get/has_stop_button | track_index, clip_index | track_index, clip_index, has_stop_button | Query whether the slot has a stop button |
+| /live/clip_slot/set/has_stop_button | track_index, clip_index, has_stop_button | | Add or remove stop button (1=on, 0=off) |
+| /live/clip_slot/start_listen/has_stop_button | track_index, clip_index | track_index, clip_index, has_stop_button | Listen for has_stop_button changes |
+| /live/clip_slot/stop_listen/has_stop_button | track_index, clip_index | | Stop listening for has_stop_button changes |
+| /live/clip_slot/get/is_group_slot | track_index, clip_index | track_index, clip_index, is_group_slot | Query whether the slot is a group slot |
+| /live/clip_slot/get/is_playing | track_index, clip_index | track_index, clip_index, is_playing | Query whether the slot is playing (playing_status != 0) |
+| /live/clip_slot/get/is_recording | track_index, clip_index | track_index, clip_index, is_recording | Query whether the slot is recording (playing_status == 2) |
+| /live/clip_slot/get/is_triggered | track_index, clip_index | track_index, clip_index, is_triggered | Query whether the slot is triggered |
+| /live/clip_slot/start_listen/is_triggered | track_index, clip_index | track_index, clip_index, is_triggered | Listen for is_triggered changes |
+| /live/clip_slot/stop_listen/is_triggered | track_index, clip_index | | Stop listening for is_triggered changes |
+| /live/clip_slot/get/playing_status | track_index, clip_index | track_index, clip_index, playing_status | Query slot playing_status: At least one clip in Group Track slot is (1=playing, 2=recording) |
+| /live/clip_slot/start_listen/playing_status | track_index, clip_index | track_index, clip_index, playing_status | Listen for playing_status changes |
+| /live/clip_slot/stop_listen/playing_status | track_index, clip_index | | Stop listening for playing_status changes |
+| /live/clip_slot/get/will_record_on_start | track_index, clip_index | track_index, clip_index, will_record_on_start | Query whether slot will record on start |
+| /live/clip_slot/create_clip | track_index, clip_index, length | | Create a MIDI clip in the slot (kept for backwards-compatibility) |
+| /live/clip_slot/delete_clip | track_index, clip_index | | Delete the clip in the slot (kept for backwards-compatibility) |
@@ -345,76 +373,197 @@ A Clip Slot represents a container for a clip. It is used to create and delete c
## Clip API
-Represents an audio or MIDI clip. Can be used to start/stop clips, and query/modify their notes, name, gain, pitch, color, playing state/position, etc.
+Represents an audio or MIDI clip in session view. Can be used to start/stop clips, and query/modify their notes, name, gain, pitch, color, playing state/position, etc.
Documentation: Clip API
-| Address | Query params | Response params | Description |
-|:-----------------------------------------|:--------------------------------------------------------------------|:---------------------------------------------------------------------------------------|:---------------------------------------------------------------------------------------------------------------------------------------------------------|
-| /live/clip/fire | track_id, clip_id | | Start clip playback |
-| /live/clip/stop | track_id, clip_id | | Stop clip playback |
-| /live/clip/duplicate_loop | track_id, clip_id | | Duplicates clip loop |
-| /live/clip/get/notes | track_id, clip_id, [start_pitch, pitch_span, start_time, time_span] | track_id, clip_id, pitch, start_time, duration, velocity, mute, [pitch, start_time...] | Query the notes in a given clip, optionally including a start time/pitch and time/pitch span. |
-| /live/clip/add/notes | track_id, clip_id, pitch, start_time, duration, velocity, mute, ... | | Add new MIDI notes to a clip. pitch is MIDI note index, start_time and duration are beats in floats, velocity is MIDI velocity index, mute is true/false |
-| /live/clip/remove/notes | [start_pitch, pitch_span, start_time, time_span] | | Remove notes from a clip in a range of pitches and times. If no ranges specified, all notes are removed. Note that ordering has changed as of 2023-11. |
-| /live/clip/get/color | track_id, clip_id | track_id, clip_id, color | Get clip color |
-| /live/clip/set/color | track_id, clip_id, color | | Set clip color |
-| /live/clip/get/color_index | track_id, clip_id | track_id, clip_id, color_index | Get clip color index (0-69) |
-| /live/clip/set/color_index | track_id, clip_id, color_index | | Set clip color index (0-69) |
-| /live/clip/get/name | track_id, clip_id | track_id, clip_id, name | Get clip name |
-| /live/clip/set/name | track_id, clip_id, name | | Set clip name |
-| /live/clip/get/gain | track_id, clip_id | track_id, clip_id, gain | Get clip gain |
-| /live/clip/set/gain | track_id, clip_id, gain | | Set clip gain |
-| /live/clip/get/length | track_id, clip_id | track_id, clip_id, length | Get clip length |
-| /live/clip/get/sample_length | track_id, clip_id | track_id, clip_id, sample_length | Get clip sample length |
-| /live/clip/get/start_time | track_id, clip_id | track_id, clip_id, start_time | Get clip start time |
-| /live/clip/get/pitch_coarse | track_id, clip_id | track_id, clip_id, semitones | Get clip coarse re-pitch |
-| /live/clip/set/pitch_coarse | track_id, clip_id, semitones | | Set clip coarse re-pitch |
-| /live/clip/get/pitch_fine | track_id, clip_id | track_id, clip_id, cents | Get clip fine re-pitch |
-| /live/clip/set/pitch_fine | track_id, clip_id, cents | | Set clip fine re-pitch |
-| /live/clip/get/file_path | track_id, clip_id | track_id, clip_id, file_path | Get clip file path |
-| /live/clip/get/is_audio_clip | track_id, clip_id | track_id, clip_id, is_audio_clip | Query whether clip is audio |
-| /live/clip/get/is_midi_clip | track_id, clip_id | track_id, clip_id, is_midi_clip | Query whether clip is MIDI |
-| /live/clip/get/is_playing | track_id, clip_id | track_id, clip_id, is_playing | Query whether clip is playing |
-| /live/clip/get/is_overdubbing | track_id, clip_id | track_id, clip_id, is_overdubbing | Query whether clip is overdubbing |
-| /live/clip/get/is_recording | track_id, clip_id | track_id, clip_id, is_recording | Query whether clip is recording |
-| /live/clip/get/will_record_on_start | track_id, clip_id | track_id, clip_id, will_record_on_start | Query whether clip will record on start |
-| /live/clip/get/playing_position | track_id, clip_id | track_id, clip_id, playing_position | Get clip's playing position |
-| /live/clip/start_listen/playing_position | track_id, clip_id | | Start listening for clip's playing position. Replies are sent to /live/clip/get/playing_position, with args: track_id, clip_id, playing_position |
-| /live/clip/stop_listen/playing_position | track_id, clip_id | | Stop listening for clip's playing position. |
-| /live/clip/get/loop_start | track_id, clip_id | track_id, clip_id, loop_start | Get clip's loop start |
-| /live/clip/set/loop_start | track_id, clip_id, loop_start | | Set clip's loop start |
-| /live/clip/get/loop_end | track_id, clip_id | track_id, clip_id, loop_end | Get clip's loop end |
-| /live/clip/set/loop_end | track_id, clip_id, loop_end | | Set clip's loop end |
-| /live/clip/get/warping | track_id, clip_id | track_id, clip_id, warping | Get clip's warp mode |
-| /live/clip/set/warping | track_id, clip_id, warping | | Set clip's warp mode |
-| /live/clip/get/launch_mode | track_id, clip_id | track_id, clip_id, launch_mode | Get clip's launch mode (0=Trigger, 1=Gate, 2=Toggle, 3=Repeat) |
-| /live/clip/set/launch_mode | track_id, clip_id, launch_mode | | Set clip's launch mode (0=Trigger, 1=Gate, 2=Toggle, 3=Repeat) |
-| /live/clip/get/launch_quantization | track_id, clip_id | track_id, clip_id, launch_quantization | Get clip's launch Quantization Value (0=Global, 1=None, 2=8Bars, 3=4Bars, 4=2Bars, 5=1Bar, 6=1/2, 7=1/2T, 8=1/4, 9=1/4T, 10=1/8, 11=1/8T, 12=1/16, 13=1/16T, 14=1/32) |
-| /live/clip/set/launch_quantization | track_id, clip_id, launch_quantization | | Set clip's launch Quantization Value (0=Global, 1=None, 2=8Bars, 3=4Bars, 4=2Bars, 5=1Bar, 6=1/2, 7=1/2T, 8=1/4, 9=1/4T, 10=1/8, 11=1/8T, 12=1/16, 13=1/16T, 14=1/32) |
-| /live/clip/get/ram_mode | track_id, clip_id | track_id, clip_id, ram_mode | Get clip's Ram Mode (0=False, 1=True) |
-| /live/clip/set/ram_mode | track_id, clip_id, ram_mode | | Set clip's Ram Mode (0=False, 1=True) |
-| /live/clip/get/warp_mode | track_id, clip_id | track_id, clip_id, warp_mode | Get clip's Warp Mode (0=Beats, 1=Tones, 2=Texture, 3=Re-Pitch, 4=Complex, 5=Invalid/Error, 6=Pro) |
-| /live/clip/set/warp_mode | track_id, clip_id, warp_mode | | Set clip's Warp Mode (0=Beats, 1=Tones, 2=Texture, 3=Re-Pitch, 4=Complex, 5=Invalid/Error, 6=Pro) |
-| /live/clip/get/has_groove | track_id, clip_id | track_id, clip_id, has_groove | Get clip Groove state (0=False, 1=True)
-| /live/clip/get/legato | track_id, clip_id | track_id, clip_id, legato | Get clip's Legato state (0=False, 1=True) |
-| /live/clip/set/legato | track_id, clip_id, legato | | Set clip's Legato state (0=False, 1=True) |
-| /live/clip/get/position | track_id, clip_id | track_id, clip_id, position | Get clip's position (LoopStart) |
-| /live/clip/set/position | track_id, clip_id, position | | Set clip's position (LoopStart) |
-| /live/clip/get/muted | track_id, clip_id | track_id, clip_id, muted | Get clip's Muted state (0=False, 1=True) |
-| /live/clip/set/muted | track_id, clip_id, muted | | Set clip's Muted state (0=False, 1=True) |
-| /live/clip/get/velocity_amount | track_id, clip_id | track_id, clip_id, velocity_amount | Get clip's Velocity Amount (0.0-1.0 aka 0% to 100%) |
-| /live/clip/set/velocity_amount | track_id, clip_id, velocity_amount | | Set clip's Velocity Amount (0.0-1.0 aka 0% to 100%) |
-| /live/clip/get/start_marker | track_id, clip_id | track_id, clip_id, start_marker | Get clip's start marker |
-| /live/clip/set/start_marker | track_id, clip_id, start_marker | | Set clip's start marker, expressed in floating-point beats |
-| /live/clip/get/end_marker | track_id, clip_id | track_id, clip_id, end_marker | Get clip's end marker |
-| /live/clip/set/end_marker | track_id, clip_id, end_marker | | Set clip's end marker, expressed in floating-point beats |
-
+### Playback / Transport
+
+Playback actions plus timeline/loop state properties that affect clip transport.
+
+| Address | Query params | Response params | Description |
+| -------------------------------------------------- | ------------------------------------------ | ------------------------ | -------------------------------------------------- |
+| /live/clip/fire | track_id, clip_id | | Start clip playback |
+| /live/clip/stop | track_id, clip_id | | Stop clip playback |
+| /live/clip/set/fire_button | track_id, clip_id, state | | Set the clip’s fire button state |
+| /live/clip/scrub | track_id, clip_id, scrub_pos | | Begin scrubbing the clip at scrub_pos (beats) |
+| /live/clip/stop/scrub | track_id, clip_id | | Stop scrubbing the clip |
+| /live/clip/move/playing_pos | track_id, clip_id, delta_beats | | Move the current playing position by a beat offset |
+| /live/clip/crop | track_id, clip_id | | Crop clip to current loop |
+| /live/clip/duplicate/loop | track_id, clip_id | | Duplicate the clip loop (preferred name) |
+| /live/clip/quantize | track_id, clip_id, quantize, amount | | Quantize notes (MIDI) or warp markers (audio) |
+| /live/clip/quantize/pitch | track_id, clip_id, pitch, quantize, amount | | Quantize notes at a pitch (MIDI clips only) |
+| /live/clip/get/start_marker | track_id, clip_id | track_id, clip_id, value | Start marker |
+| /live/clip/set/start_marker | track_id, clip_id, value | | Start marker |
+| /live/clip/[start/stop]_listen/start_marker | track_id, clip_id | track_id, clip_id, value | Start marker |
+| /live/clip/get/end_marker | track_id, clip_id | track_id, clip_id, value | End marker (beats/seconds) |
+| /live/clip/set/end_marker | track_id, clip_id, value | | End marker (beats/seconds) |
+| /live/clip/[start/stop]_listen/end_marker | track_id, clip_id | track_id, clip_id, value | End marker (beats/seconds) |
+| /live/clip/get/start_time | track_id, clip_id | track_id, clip_id, value | Start time |
+| /live/clip/[start/stop]_listen/start_time | track_id, clip_id | track_id, clip_id, value | Start time |
+| /live/clip/get/end_time | track_id, clip_id | track_id, clip_id, value | End time (beats/seconds) |
+| /live/clip/[start/stop]_listen/end_time | track_id, clip_id | track_id, clip_id, value | End time (beats/seconds) |
+| /live/clip/set/looping | track_id, clip_id, value | | Loop enabled |
+| /live/clip/[start/stop]_listen/looping | track_id, clip_id | track_id, clip_id, value | Loop enabled |
+| /live/clip/get/loop_start | track_id, clip_id | track_id, clip_id, value | Loop start |
+| /live/clip/set/loop_start | track_id, clip_id, value | | Loop start |
+| /live/clip/[start/stop]_listen/loop_start | track_id, clip_id | track_id, clip_id, value | Loop start |
+| /live/clip/get/loop_end | track_id, clip_id | track_id, clip_id, value | Loop end |
+| /live/clip/set/loop_end | track_id, clip_id, value | | Loop end |
+| /live/clip/[start/stop]_listen/loop_end | track_id, clip_id | track_id, clip_id, value | Loop end |
+| /live/clip/[start/stop]_listen/loop_jump | track_id, clip_id | track_id, clip_id, 1 | Bang when loop crosses start |
+| /live/clip/get/looping | track_id, clip_id | track_id, clip_id, value | Loop enabled |
+| /live/clip/get/is_overdubbing | track_id, clip_id | track_id, clip_id, value | Overdubbing flag |
+| /live/clip/[start/stop]_listen/is_overdubbing | track_id, clip_id | track_id, clip_id, value | Overdubbing flag |
+| /live/clip/get/is_playing | track_id, clip_id | track_id, clip_id, value | Play state (setter supported) |
+| /live/clip/set/is_playing | track_id, clip_id, value | | Play state (setter supported) |
+| /live/clip/get/is_recording | track_id, clip_id | track_id, clip_id, value | Recording flag |
+| /live/clip/[start/stop]_listen/is_recording | track_id, clip_id | track_id, clip_id, value | Recording flag |
+| /live/clip/get/is_triggered | track_id, clip_id | track_id, clip_id, value | Triggered flag |
+| /live/clip/get/launch_mode | track_id, clip_id | track_id, clip_id, value | Launch mode |
+| /live/clip/set/launch_mode | track_id, clip_id, value | | Launch mode |
+| /live/clip/[start/stop]_listen/launch_mode | track_id, clip_id | track_id, clip_id, value | Launch mode |
+| /live/clip/get/launch_quantization | track_id, clip_id | track_id, clip_id, value | Launch quantization |
+| /live/clip/set/launch_quantization | track_id, clip_id, value | | Launch quantization |
+| /live/clip/[start/stop]_listen/launch_quantization | track_id, clip_id | track_id, clip_id, value | Launch quantization |
+| /live/clip/get/legato | track_id, clip_id | track_id, clip_id, value | Legato flag |
+| /live/clip/set/legato | track_id, clip_id, value | | Legato flag |
+| /live/clip/[start/stop]_listen/legato | track_id, clip_id | track_id, clip_id, value | Legato flag |
+| /live/clip/get/playing_position | track_id, clip_id | track_id, clip_id, value | Playing position |
+| /live/clip/[start/stop]_listen/playing_position | track_id, clip_id | track_id, clip_id, value | Playing position |
+| /live/clip/[start/stop]_listen/playing_status | track_id, clip_id | track_id, clip_id, 1 | Bang when play/trigger state changes |
+| /live/clip/get/position | track_id, clip_id | track_id, clip_id, value | Loop position |
+| /live/clip/set/position | track_id, clip_id, value | | Loop position |
+| /live/clip/[start/stop]_listen/position | track_id, clip_id | track_id, clip_id, value | Loop position |
+
+### Warping (Audio Clips)
+
+Audio-only warp marker operations and warp-related properties, plus time conversion for warped clips.
+
+| Address | Query params | Response params | Description |
+| ------------------------------------------- | --------------------------------------------- | ------------------------------------------------ | ----------------------------------------------------- |
+| /live/clip/add/warp_marker | track_id, clip_id, beat_time[, sample_time] | | Add a warp marker at beat_time (sample_time optional) |
+| /live/clip/move/warp_marker | track_id, clip_id, beat_time, beat_time_delta | | Move a warp marker by beat_time_delta |
+| /live/clip/remove/warp_marker | track_id, clip_id, beat_time | | Remove a warp marker at beat_time |
+| /live/clip/get/available_warp_modes | track_id, clip_id | track_id, clip_id, warp_mode… | Available warp modes |
+| /live/clip/get/warp_mode | track_id, clip_id | track_id, clip_id, value | Warp mode |
+| /live/clip/set/warp_mode | track_id, clip_id, value | | Warp mode |
+| /live/clip/[start/stop]_listen/warp_mode | track_id, clip_id | track_id, clip_id, value | Warp mode |
+| /live/clip/get/warp_markers | track_id, clip_id | track_id, clip_id, beat_time_1, sample_time_1, … | Warp markers (shadow marker dropped) |
+| /live/clip/[start/stop]_listen/warp_markers | track_id, clip_id | track_id, clip_id, beat_time_1, sample_time_1, … | Warp markers (shadow marker dropped) |
+| /live/clip/get/warping | track_id, clip_id | track_id, clip_id, value | Warping enabled |
+| /live/clip/set/warping | track_id, clip_id, value | | Warping enabled |
+| /live/clip/[start/stop]_listen/warping | track_id, clip_id | track_id, clip_id, value | Warping enabled |
+| /live/clip/convert/time | track_id, clip_id, unit_from, unit_to, value | track_id, clip_id, value | Convert time between samples/seconds/beats |
+
+### Notes (MIDI Clips)
+
+MIDI-only note endpoints and note-number conversion.
+
+| Address | Query params | Response params | Description |
+| -------------------------------------- | --------------------------------------------------------------------------------- | ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------- |
+| /live/clip/add/notes | track_id, clip_id, pitch, start, duration, velocity, mute, … | | Add notes |
+| /live/clip/get/notes | track_id, clip_id[, pitch_start, pitch_span, time_start, time_span] | track_id, clip_id, pitch, start, duration, velocity, mute, … | Get notes in range (or all if no range) |
+| /live/clip/get/selected_notes | track_id, clip_id | track_id, clip_id, pitch, start, duration, velocity, mute, … | Get selected notes |
+| /live/clip/[start/stop]_listen/notes | track_id, clip_id | track_id, clip_id, notes… | Notes listener |
+| /live/clip/replace/notes | track_id, clip_id[, pitch_start, pitch_span, time_start, time_span], notes… | | Replace notes in range (or all if no range) |
+| /live/clip/replace/selected_notes | track_id, clip_id, notes… | | Replace selected notes |
+| /live/clip/remove/notes | track_id, clip_id[, pitch_start, pitch_span, time_start, time_span] | | Remove notes in range (or all if no range) |
+| /live/clip/remove/selected_notes | track_id, clip_id | | Remove selected notes |
+| /live/clip/duplicate/all_notes | track_id, clip_id[, destination_time, transposition] | | Duplicate all notes. Default destination is end of last note |
+| /live/clip/duplicate/region | track_id, clip_id, from_time, time_span[, destination_time, pitch, transposition] | | Duplicate notes in a region. Default destination is end of time_span. Optionaly filter to a single pitch. |
+| /live/clip/duplicate/selected_notes | track_id, clip_id[, destination_time, transposition] | | Duplicate selected notes. Default destination is end of last note |
+| /live/clip/select/notes | track_id, clip_id[, pitch_start, pitch_span, time_start, time_span] | | Select notes in range (or all if no range) |
+| /live/clip/deselect/notes | track_id, clip_id[, pitch_start, pitch_span, time_start, time_span] | | Deselect notes in range (or all if no range) |
+| /live/clip/convert/note_number_to_name | track_id, clip_id, note_number | track_id, clip_id, name | Convert MIDI note number to name |
+
+### Clip.View
+
+Clip View UI actions and grid display properties.
+
+| Address | Query params | Response params | Description |
+| ------------------------------------- | ------------------------ | ------------------------ | ----------------------------- |
+| /live/clip/view/show/loop | track_id, clip_id | | Show loop braces in Clip View |
+| /live/clip/view/show/envelope | track_id, clip_id | | Show envelopes in Clip View |
+| /live/clip/view/hide/envelope | track_id, clip_id | | Hide envelopes in Clip View |
+| /live/clip/view/get/grid_is_triplet | track_id, clip_id | track_id, clip_id, value | Grid triplet flag |
+| /live/clip/view/set/grid_is_triplet | track_id, clip_id, value | | Grid triplet flag |
+| /live/clip/view/get/grid_quantization | track_id, clip_id | track_id, clip_id, value | Grid quantization |
+| /live/clip/view/set/grid_quantization | track_id, clip_id, value | | Grid quantization |
+
+### General Clip Properties
+
+General properties common to both audio and MIDI clips.
+
+| Address | Query params | Response params | Description |
+| ---------------------------------------------------- | ------------------------ | ------------------------ | --------------------------- |
+| /live/clip/get/color | track_id, clip_id | track_id, clip_id, value | Clip color (RGB int) |
+| /live/clip/set/color | track_id, clip_id, value | | Clip color (RGB int) |
+| /live/clip/[start/stop]_listen/color | track_id, clip_id | track_id, clip_id, value | Clip color (RGB int) |
+| /live/clip/get/color_index | track_id, clip_id | track_id, clip_id, value | Clip color index |
+| /live/clip/set/color_index | track_id, clip_id, value | | Clip color index |
+| /live/clip/[start/stop]_listen/color_index | track_id, clip_id | track_id, clip_id, value | Clip color index |
+| /live/clip/get/has_envelopes | track_id, clip_id | track_id, clip_id, value | True if any envelopes exist |
+| /live/clip/[start/stop]_listen/has_envelopes | track_id, clip_id | track_id, clip_id, value | True if any envelopes exist |
+| /live/clip/get/has_groove | track_id, clip_id | track_id, clip_id, value | True if groove is assigned |
+| /live/clip/get/is_arrangement_clip | track_id, clip_id | track_id, clip_id, value | Arrangement clip flag |
+| /live/clip/get/is_session_clip | track_id, clip_id | track_id, clip_id, value | Session clip flag |
+| /live/clip/get/is_take_lane_clip | track_id, clip_id | track_id, clip_id, value | Take lane clip flag |
+| /live/clip/get/is_audio_clip | track_id, clip_id | track_id, clip_id, value | Audio clip flag |
+| /live/clip/get/is_midi_clip | track_id, clip_id | track_id, clip_id, value | MIDI clip flag |
+| /live/clip/get/length | track_id, clip_id | track_id, clip_id, value | Clip length |
+| /live/clip/get/muted | track_id, clip_id | track_id, clip_id, value | Mute flag |
+| /live/clip/set/muted | track_id, clip_id, value | | Mute flag |
+| /live/clip/[start/stop]_listen/muted | track_id, clip_id | track_id, clip_id, value | Mute flag |
+| /live/clip/get/name | track_id, clip_id | track_id, clip_id, value | Clip name |
+| /live/clip/set/name | track_id, clip_id, value | | Clip name |
+| /live/clip/[start/stop]_listen/name | track_id, clip_id | track_id, clip_id, value | Clip name |
+| /live/clip/get/signature_denominator | track_id, clip_id | track_id, clip_id, value | Signature denominator |
+| /live/clip/set/signature_denominator | track_id, clip_id, value | | Signature denominator |
+| /live/clip/[start/stop]_listen/signature_denominator | track_id, clip_id | track_id, clip_id, value | Signature denominator |
+| /live/clip/get/signature_numerator | track_id, clip_id | track_id, clip_id, value | Signature numerator |
+| /live/clip/set/signature_numerator | track_id, clip_id, value | | Signature numerator |
+| /live/clip/[start/stop]_listen/signature_numerator | track_id, clip_id | track_id, clip_id, value | Signature numerator |
+| /live/clip/get/velocity_amount | track_id, clip_id | track_id, clip_id, value | Velocity amount |
+| /live/clip/set/velocity_amount | track_id, clip_id, value | | Velocity amount |
+| /live/clip/[start/stop]_listen/velocity_amount | track_id, clip_id | track_id, clip_id, value | Velocity amount |
+| /live/clip/get/will_record_on_start | track_id, clip_id | track_id, clip_id, value | Will record on start |
+
+### Audio Clip Properties
+
+Audio clip only properties (file path, gain, pitch, RAM mode, and sample info).
+
+| Address | Query params | Response params | Description |
+| ------------------------------------------- | ------------------------ | ------------------------ | ------------------------------------- |
+| /live/clip/get/file_path | track_id, clip_id | track_id, clip_id, value | File path (listener fires on replace) |
+| /live/clip/[start/stop]_listen/file_path | track_id, clip_id | track_id, clip_id, value | File path (listener fires on replace) |
+| /live/clip/get/gain | track_id, clip_id | track_id, clip_id, value | Clip gain |
+| /live/clip/set/gain | track_id, clip_id, value | | Clip gain |
+| /live/clip/[start/stop]_listen/gain | track_id, clip_id | track_id, clip_id, value | Clip gain |
+| /live/clip/get/gain_display_string | track_id, clip_id | track_id, clip_id, value | Gain display string |
+| /live/clip/get/pitch_coarse | track_id, clip_id | track_id, clip_id, value | Pitch coarse |
+| /live/clip/set/pitch_coarse | track_id, clip_id, value | | Pitch coarse |
+| /live/clip/[start/stop]_listen/pitch_coarse | track_id, clip_id | track_id, clip_id, value | Pitch coarse |
+| /live/clip/get/pitch_fine | track_id, clip_id | track_id, clip_id, value | Pitch fine |
+| /live/clip/set/pitch_fine | track_id, clip_id, value | | Pitch fine |
+| /live/clip/[start/stop]_listen/pitch_fine | track_id, clip_id | track_id, clip_id, value | Pitch fine |
+| /live/clip/get/ram_mode | track_id, clip_id | track_id, clip_id, value | RAM mode (audio) |
+| /live/clip/set/ram_mode | track_id, clip_id, value | | RAM mode (audio) |
+| /live/clip/[start/stop]_listen/ram_mode | track_id, clip_id | track_id, clip_id, value | RAM mode (audio) |
+| /live/clip/get/sample_length | track_id, clip_id | track_id, clip_id, value | Sample length |
+| /live/clip/get/sample_rate | track_id, clip_id | track_id, clip_id, value | Sample rate |
---
+## Arrangement Clip API
+
+Represents an audio or MIDI clip in Arrangement view. Endpoints mirror `/live/clip/*` but use the
+`/live/arrangement_clip/*` namespace. Clips are indexed by the order in which they appear in the arrangement.
+
+---
+
## Scene API
Represents a scene, used to trigger a row of clips simultaneously. A scene's name, color, tempo and time signature can all be set and queried.
@@ -557,4 +706,3 @@ For code contributions and feedback, many thanks to:
- Mark Marijnissen ([markmarijnissen](https://github.com/markmarijnissen))
- [capturcus](https://github.com/capturcus)
- Esa Ruoho a.k.a. Lackluster ([esaruoho](https://github.com/esaruoho))
-
diff --git a/abletonosc/__init__.py b/abletonosc/__init__.py
index 53ba155..e4bbf33 100644
--- a/abletonosc/__init__.py
+++ b/abletonosc/__init__.py
@@ -6,7 +6,7 @@
from .osc_server import OSCServer
from .application import ApplicationHandler
from .song import SongHandler
-from .clip import ClipHandler
+from .clip import ClipHandler, ClipViewHandler
from .clip_slot import ClipSlotHandler
from .track import TrackHandler
from .device import DeviceHandler
diff --git a/abletonosc/clip.py b/abletonosc/clip.py
index ce29fa0..d86445a 100644
--- a/abletonosc/clip.py
+++ b/abletonosc/clip.py
@@ -1,5 +1,5 @@
import re
-from typing import Tuple, Callable, Any, Optional
+from typing import Tuple, Any
from .handler import AbletonOSCHandler
import Live
@@ -24,6 +24,11 @@ def note_name_to_midi(name):
return index
return None
+def _chunk(params: Tuple[Any], size: int):
+ if len(params) % size != 0:
+ raise ValueError("Invalid number of arguments. Expected input to split into %d-size chunks." % size)
+ return (params[i:i + size] for i in range(0, len(params), size))
+
class ClipHandler(AbletonOSCHandler):
def __init__(self, manager):
super().__init__(manager)
@@ -31,7 +36,7 @@ def __init__(self, manager):
self._clip_notes_cache = []
def init_api(self):
- def create_clip_callback(func, *args, pass_clip_index=False):
+ def create_clip_callback(func, *args, pass_clip_index=False, **kwargs):
"""
Creates a callback that expects the following set of arguments:
(track_index, clip_index, *args)
@@ -54,82 +59,266 @@ def clip_callback(params: Tuple[Any]) -> Tuple:
track = self.song.tracks[track_index]
clip = track.clip_slots[clip_index].clip
if pass_clip_index:
- rv = func(clip, *args, tuple(params[0:]))
+ rv = func(clip, *args, tuple(params[0:]), **kwargs)
+ else:
+ rv = func(clip, *args, tuple(params[2:]), **kwargs)
+
+ if rv is not None:
+ return (track_index, clip_index, *rv)
+
+ return clip_callback
+
+ def create_arrangement_clip_callback(func, *args, pass_clip_index=False, **kwargs):
+ """
+ Creates a callback that expects: (track_index, arrangement_clip_index, *args)
+ and targets track.arrangement_clips[clip_index].
+ """
+ def clip_callback(params: Tuple[Any]) -> Tuple:
+ track_index, clip_index = int(params[0]), int(params[1])
+ track = self.song.tracks[track_index]
+ clip = track.arrangement_clips[clip_index]
+ if pass_clip_index:
+ rv = func(clip, *args, tuple(params[0:]), **kwargs)
else:
- rv = func(clip, *args, tuple(params[2:]))
+ rv = func(clip, *args, tuple(params[2:]), **kwargs)
if rv is not None:
return (track_index, clip_index, *rv)
return clip_callback
+
+
+ # --- Method / Property Definitions --- #
+ methods = {
+ # Playback / Transport
+ "fire": {"alias": 0, "caller": 1},
+ "stop": {"alias": 0, "caller": 1},
+ "set/fire_button": {"alias": 1, "caller": "set_fire_button_state"},
+ "scrub": {"alias": 0, "caller": 1},
+ "stop/scrub": {"alias": 1, "caller": "stop_scrub"},
+ "move/playing_pos": {"alias": 1, "caller": "move_playing_pos"},
+ "crop": {"alias": 0, "caller": 1},
+ "duplicate_loop": {"alias": 0, "caller": 1}, # Already in master, kept for back-compat
+ "duplicate/loop": {"alias": 1, "caller": "duplicate_loop"},
+ "quantize": {"alias": 0, "caller": 1},
+ "quantize/pitch": {"alias": 1, "caller": "quantize_pitch"},
+
+ # Warp / Time Conversions
+ "add/warp_marker": {"alias": 0, "caller": "clip_add_warp_marker"},
+ "move/warp_marker": {"alias": 1, "caller": "move_warp_marker"},
+ "remove/warp_marker": {"alias": 1, "caller": "remove_warp_marker"},
+ "convert/time": {"alias": 0, "caller": "clip_convert_time"},
+
+ # Notes
+ "add/notes": {"alias": 0, "caller": "clip_add_notes"},
+ "get/notes": {"alias": 0, "caller": "clip_get_notes"}, # Warns
+ "get/selected_notes": {"alias": 0, "caller": "clip_get_selected_notes"}, # Warns
+ "replace/notes": {"alias": 0, "caller": "clip_replace_notes"}, # Warns
+ "replace/selected_notes": {"alias": 0, "caller": "clip_replace_selected_notes"}, # Warns
+ "remove/notes": {"alias": 0, "caller": "clip_remove_notes"},
+ "remove/selected_notes": {"alias": 0, "caller": "clip_remove_selected_notes"},
+ "duplicate/all_notes": {"alias": 0, "caller": "clip_duplicate_all_notes"},
+ "duplicate/region": {"alias": 0, "caller": "clip_duplicate_region"},
+ "duplicate/selected_notes": {"alias": 0, "caller": "clip_duplicate_selected_notes"},
+ "select/notes": {"alias": 0, "caller": "clip_select_notes"},
+ "deselect/notes": {"alias": 0, "caller": "clip_deselect_notes"},
+ "convert/note_number_to_name": {"alias": 0, "caller": "clip_note_number_to_name"},
+
+ # # By ID, TODO: Need to decide on how to implement extended note format while keeping back-compat.
+ # "modify/notes": {"alias": 0, "caller": "clip_apply_note_modifications"},
+ # "get/notes_by_id": {"alias": 0, "caller": "clip_get_notes_by_id"},
+ # "replace/notes_by_id": {"alias": 0, "caller": "clip_replace_notes_by_id"},
+ # "remove_notes_by_id": {"alias": 0, "caller": None}, # Remove undocumented/unusable back-compat
+ # "remove/notes_by_id": {"alias": 1, "caller": "remove_notes_by_id"},
+ # "duplicate/notes_by_id": {"alias": 0, "caller": None}, # TODO: Decide on format since arg length is variable with optional time/transpose
+ # "select/notes_by_id": {"alias": 1, "caller": "select_notes_by_id"},
+ # "deselect/notes_by_id": {"alias": 0, "caller": "clip_deselect_notes_by_id"},
+
+ # Automation / envelopes
+ # TODO: Envelope objects
+ "automation/envelope": {"alias": 0, "caller": None},
+ "create/automation_envelope": {"alias": 0, "caller": None},
+ "clear/envelope": {"alias": 0, "caller": None},
+ "clear/all_envelopes": {"alias": 0, "caller": None},
+ }
+
+ properties = {
+ "automation_envelopes": {"get": 0, "set": 0, "listen": 0}, # TODO: Const access to a list of all automation envelopes for this clip.
+ "available_warp_modes": {"get": "clip_get_available_warp_modes", "set": 0, "listen": 0},
+ "canonical_parent": {"get": 0, "set": 0, "listen": 0}, # TODO: ClipSlot object: Not serializable
+ "color": {"get": 1, "set": 1, "listen": 1},
+ "color_index": {"get": 1, "set": 1, "listen": 1},
+ "end_marker": {"get": 1, "set": 1, "listen": 1},
+ "end_time": {"get": 1, "set": 0, "listen": 1},
+ "file_path": {"get": 1, "set": 0, "listen": 1}, # Listener triggers with replace file actions.
+ "gain": {"get": 1, "set": 1, "listen": 1},
+ "gain_display_string": {"get": 1, "set": 0, "listen": 0},
+ "groove": {"get": 0, "set": 0, "listen": 0}, # TODO: Groove object; Need groove module
+ "has_envelopes": {"get": 1, "set": 0, "listen": 1},
+ "has_groove": {"get": 1, "set": 0, "listen": 0},
+ "is_arrangement_clip": {"get": 1, "set": 0, "listen": 0},
+ "is_midi_clip": {"get": 1, "set": 0, "listen": 0},
+ "is_audio_clip": {"get": 1, "set": 0, "listen": 0},
+ "is_overdubbing": {"get": 1, "set": 0, "listen": 1},
+ "is_playing": {"get": 1, "set": 1, "listen": 0},
+ "is_recording": {"get": 1, "set": 0, "listen": 1},
+ "is_session_clip": {"get": 1, "set": 0, "listen": 0},
+ "is_take_lane_clip": {"get": 1, "set": 0, "listen": 0},
+ "is_triggered": {"get": 1, "set": 0, "listen": 0},
+ "launch_mode": {"get": 1, "set": 1, "listen": 1},
+ "launch_quantization": {"get": 1, "set": 1, "listen": 1},
+ "legato": {"get": 1, "set": 1, "listen": 1},
+ "length": {"get": 1, "set": 0, "listen": 0},
+ "loop_end": {"get": 1, "set": 1, "listen": 1},
+ "loop_jump": {"get": 0, "set": 0, "listen": "bang"}, # Sends bang (1) when the loop crosses the start marker.
+ "loop_start": {"get": 1, "set": 1, "listen": 1},
+ "looping": {"get": 1, "set": 1, "listen": 1},
+ "muted": {"get": 1, "set": 1, "listen": 1},
+ "name": {"get": 1, "set": 1, "listen": 1},
+ "notes": {"get": 0, "set": 0, "listen": "clip_get_notes_listener"},
+ "pitch_coarse": {"get": 1, "set": 1, "listen": 1},
+ "pitch_fine": {"get": 1, "set": 1, "listen": 1},
+ "playing_position": {"get": 1, "set": 0, "listen": 1},
+ "playing_status": {"get": 0, "set": 0, "listen": "bang"}, # bangs when playing/trigger state changes. Check is_playing/is_triggerd for details
+ "position": {"get": 1, "set": 1, "listen": 1},
+ "ram_mode": {"get": 1, "set": 1, "listen": 1},
+ "sample_length": {"get": 1, "set": 0, "listen": 0},
+ "sample_rate": {"get": 1, "set": 0, "listen": 0},
+ "signature_denominator": {"get": 1, "set": 1, "listen": 1},
+ "signature_numerator": {"get": 1, "set": 1, "listen": 1},
+ "start_marker": {"get": 1, "set": 1, "listen": 1},
+ "start_time": {"get": 1, "set": 0, "listen": 1},
+ "velocity_amount": {"get": 1, "set": 1, "listen": 1},
+ "warp_mode": {"get": 1, "set": 1, "listen": 1},
+ "warp_markers": {"get": "clip_get_warp_markers", "set": 0, "listen": "clip_get_warp_markers_listener"},
+ "warping": {"get": 1, "set": 1, "listen": 1},
+ "will_record_on_start": {"get": 1, "set": 0, "listen": 0},
+ }
+
+ # --- Custom Callers --- #
+
+ # Warping / Time
+ def clip_get_available_warp_modes(clip, _):
+ value = tuple(int(mode) for mode in clip.available_warp_modes)
+ self.logger.info("Getting property for %s: available_warp_modes = %s" % (self.class_identifier, value))
+ return value
+
+ def clip_get_warp_markers(clip, _, log: bool = True):
+ markers = clip.warp_markers
+ # Drop trailing shadow marker (used internally to determine final segment BPM).
+ if markers:
+ markers = markers[:-1]
+ flat: list[float] = []
+ for marker in markers:
+ flat.append(getattr(marker, "beat_time", None))
+ flat.append(getattr(marker, "sample_time", None))
+ value = tuple(flat)
+ if log:
+ self.logger.info("Getting property for %s: warp_markers = %s" % (self.class_identifier, value))
+ return value
+
+ def clip_get_warp_markers_listener(clip, params: Tuple[Any] = ()):
+ return clip_get_warp_markers(clip, (), log=False)
+
+ def clip_add_warp_marker(clip, params: Tuple[Any] = ()):
+ self.logger.info("Calling method for %s: add_warp_marker (params %s)" % (self.class_identifier, params))
+ if len(params) == 1:
+ beat_time = params[0]
+ sample_time = None
+ elif len(params) == 2:
+ beat_time, sample_time = params
+ else:
+ raise ValueError("Invalid number of arguments for /clip/add_warp_marker. Pass beat_time or beat_time, sample_time.")
+
+ if sample_time is None:
+ sample_rate = clip.sample_rate
+ samples = clip.beat_to_sample_time(beat_time)
+ sample_time = samples / sample_rate
+
+ warp_marker = Live.Clip.WarpMarker(sample_time, beat_time)
+ clip.add_warp_marker(warp_marker)
+
+ def clip_convert_time(clip, params: Tuple[Any] = ()):
+ if len(params) != 3:
+ raise ValueError("Invalid number of arguments for /clip/convert/time. Expected (unit_from, unit_to, value).")
+ unit_from, unit_to, input = params
+ units = ("samples", "seconds", "beats")
+ if unit_from not in units or unit_to not in units:
+ raise ValueError("Invalid units for /clip/convert/time. Use 'samples', 'seconds', or 'beats'.")
+ if unit_from == unit_to:
+ value = (input,)
+ self.logger.info("Time conversion for %s: %s %s -> %s %s" % (
+ self.class_identifier, input, unit_from, input, unit_to))
+ return value
+
+ sample_rate = clip.sample_rate
+
+ # Beat-based conversions require a warped clip; seconds<->samples always works.
+ if unit_from == "beats":
+ samples = clip.beat_to_sample_time(input)
+ elif unit_from == "seconds":
+ samples = input * sample_rate
+ else: # already samples
+ samples = input
+
+ if unit_to == "beats":
+ value = clip.sample_to_beat_time(samples)
+ elif unit_to == "seconds":
+ value = samples / sample_rate
+ else:
+ value = samples
+ self.logger.info("Time conversion for %s: %s %s -> %s %s" % (
+ self.class_identifier, input, unit_from, value, unit_to))
+ return (value,)
+
+ # Notes
+ def clip_note_number_to_name(clip, params: Tuple[Any] = ()):
+ if len(params) < 1:
+ raise ValueError("Invalid number of arguments for /clip/convert/note_number_to_name. Expected 1 argument.")
+ value = clip.note_number_to_name(params[0])
+ self.logger.info("Converting note for %s: %s -> %s" % (self.class_identifier, params[0], value))
+ return (value,)
+
+ def clip_add_notes(clip, params: Tuple[Any] = ()):
+ if len(params) % 5 != 0:
+ raise ValueError("Invalid number of arguments for /clip/add/notes. Expected list of 5 parameters per note.")
+ self.logger.info("Calling method for %s: add_notes (params %s)" % (self.class_identifier, params))
+ notes = []
+ for pitch, start_time, duration, velocity, mute in _chunk(params, 5):
+ note = Live.Clip.MidiNoteSpecification(start_time=start_time,
+ duration=duration,
+ pitch=pitch,
+ velocity=velocity,
+ mute=mute)
+ notes.append(note)
+ clip.add_new_notes(tuple(notes))
+
+ def _notes_have_extended_attrs(notes):
+ for note in notes:
+ if (note.probability != 1.0 or
+ note.velocity_deviation != 0.0 or
+ note.release_velocity != 64):
+ return True
+ return False
+
+ def _clip_serialize_notes(notes, include_extended: bool):
+ all_note_attributes = []
+ if not include_extended and _notes_have_extended_attrs(notes):
+ self.logger.warning(
+ "Notes in clip %s have extended attributes that were not returned." % (self.class_identifier)
+ )
+ for note in notes:
+ all_note_attributes += [note.pitch, note.start_time, note.duration, note.velocity, note.mute]
+ if include_extended:
+ all_note_attributes += [
+ note.note_id,
+ note.probability,
+ note.velocity_deviation,
+ note.release_velocity,
+ ]
+ return tuple(all_note_attributes)
- methods = [
- "fire",
- "stop",
- "duplicate_loop",
- "remove_notes_by_id"
- ]
- properties_r = [
- "end_time",
- "file_path",
- "gain_display_string",
- "has_groove",
- "is_midi_clip",
- "is_audio_clip",
- "is_overdubbing",
- "is_playing",
- "is_recording",
- "is_triggered",
- "length",
- "playing_position",
- "sample_length",
- "start_time",
- "will_record_on_start"
- ## TODO list:
- ##"groove", ## if other than None, says "Error handling OSC message: Infered arg_value type is not supported"
- ## is_arrangement_clip
- ##"warp_markers", ## "Infered arg_value type is not supported"
- ##"view", ##"Infered arg_value type is not supported"
- ]
- properties_rw = [
- "color",
- "color_index",
- "end_marker",
- "gain",
- "launch_mode",
- "launch_quantization",
- "legato",
- "loop_end",
- "loop_start",
- "looping",
- "muted",
- "name",
- "pitch_coarse",
- "pitch_fine",
- "position",
- "ram_mode",
- "start_marker",
- "velocity_amount",
- "warp_mode",
- "warping",
- ]
-
- for method in methods:
- self.osc_server.add_handler("/live/clip/%s" % method,
- create_clip_callback(self._call_method, method))
-
- for prop in properties_r + properties_rw:
- self.osc_server.add_handler("/live/clip/get/%s" % prop,
- create_clip_callback(self._get_property, prop))
- self.osc_server.add_handler("/live/clip/start_listen/%s" % prop,
- create_clip_callback(self._start_listen, prop, pass_clip_index=True))
- self.osc_server.add_handler("/live/clip/stop_listen/%s" % prop,
- create_clip_callback(self._stop_listen, prop, pass_clip_index=True))
- for prop in properties_rw:
- self.osc_server.add_handler("/live/clip/set/%s" % prop,
- create_clip_callback(self._set_property, prop))
-
- def clip_get_notes(clip, params: Tuple[Any] = ()):
+ def clip_get_notes(clip, params: Tuple[Any], include_extended: bool = False, log: bool = True):
if len(params) == 4:
pitch_start, pitch_span, time_start, time_span = params
elif len(params) == 0:
@@ -137,15 +326,36 @@ def clip_get_notes(clip, params: Tuple[Any] = ()):
else:
raise ValueError("Invalid number of arguments for /clip/get/notes. Either 0 or 4 arguments must be passed.")
notes = clip.get_notes_extended(pitch_start, pitch_span, time_start, time_span)
- all_note_attributes = []
- for note in notes:
- all_note_attributes += [note.pitch, note.start_time, note.duration, note.velocity, note.mute]
- return tuple(all_note_attributes)
+ value = _clip_serialize_notes(notes, include_extended)
+ if log:
+ self.logger.info("Getting property for %s: notes%s = %s" % (self.class_identifier, "_extended" if include_extended else "", value))
+ return value
- def clip_add_notes(clip, params: Tuple[Any] = ()):
+ def clip_get_notes_listener(clip, params: Tuple[Any] = ()):
+ return clip_get_notes(clip, (), include_extended=False, log=False)
+
+ def clip_get_selected_notes(clip, params: Tuple[Any] = (), include_extended: bool = False):
+ notes = clip.get_selected_notes_extended()
+ value = _clip_serialize_notes(notes, include_extended)
+ self.logger.info("Getting property for %s: selected_notes = %s" % (self.class_identifier, value))
+ return value
+
+ def clip_replace_notes(clip, params: Tuple[Any]):
+ if len(params) % 5 == 4:
+ pitch_start, pitch_span, time_start, time_span = params[:4]
+ note_params = params[4:]
+ elif len(params) % 5 == 0:
+ pitch_start, pitch_span, time_start, time_span = 0, 127, -8192, 16384
+ note_params = params
+ else:
+ raise ValueError("Invalid number of arguments for /clip/replace/notes. Either 0 or 4 arguments plus a list of notes must be passed.")
+ self.logger.info("Calling method for %s: replace_notes (params %s)" % (self.class_identifier, params))
+ old_notes = clip.get_notes_extended(pitch_start, pitch_span, time_start, time_span)
+ if _notes_have_extended_attrs(old_notes):
+ self.logger.warning("Notes in clip %s have extended attributes that were not returned." % (self.class_identifier))
+ clip.remove_notes_extended(pitch_start, pitch_span, time_start, time_span)
notes = []
- for offset in range(0, len(params), 5):
- pitch, start_time, duration, velocity, mute = params[offset:offset + 5]
+ for pitch, start_time, duration, velocity, mute in _chunk(note_params, 5):
note = Live.Clip.MidiNoteSpecification(start_time=start_time,
duration=duration,
pitch=pitch,
@@ -153,8 +363,30 @@ def clip_add_notes(clip, params: Tuple[Any] = ()):
mute=mute)
notes.append(note)
clip.add_new_notes(tuple(notes))
-
+
+ def clip_replace_selected_notes(clip, params: Tuple[Any] = ()):
+ if len(params) % 5 != 0:
+ raise ValueError("Invalid number of arguments for /clip/replace/selected_notes. Expected list of 5 parameters per note.")
+ self.logger.info("Calling method for %s: replace_selected_notes (params %s)" % (self.class_identifier, params))
+ old_notes = clip.get_selected_notes_extended()
+ if _notes_have_extended_attrs(old_notes):
+ self.logger.warning("Selected notes in clip %s have extended attributes that were not returned." % (self.class_identifier))
+ old_ids = [n.note_id for n in old_notes]
+ if len(old_ids) == 0:
+ raise ValueError("No notes are selected for replacement.")
+ clip.remove_notes_by_id(old_ids)
+ notes = []
+ for pitch, start_time, duration, velocity, mute in _chunk(params, 5):
+ note = Live.Clip.MidiNoteSpecification(start_time=start_time,
+ duration=duration,
+ pitch=pitch,
+ velocity=velocity,
+ mute=mute)
+ notes.append(note)
+ clip.add_new_notes(tuple(notes))
+
def clip_remove_notes(clip, params: Tuple[Any] = ()):
+ self.logger.info("Calling method for %s: remove_notes (params %s)" % (self.class_identifier, params))
if len(params) == 4:
pitch_start, pitch_span, time_start, time_span = params
elif len(params) == 0:
@@ -162,11 +394,202 @@ def clip_remove_notes(clip, params: Tuple[Any] = ()):
else:
raise ValueError("Invalid number of arguments for /clip/remove/notes. Either 0 or 4 arguments must be passed.")
clip.remove_notes_extended(pitch_start, pitch_span, time_start, time_span)
+
+ def clip_remove_selected_notes(clip, params: Tuple[Any] = ()):
+ self.logger.info("Calling method for %s: remove_selected_notes (params %s)" % (self.class_identifier, params))
+ old_notes = clip.get_selected_notes_extended()
+ if _notes_have_extended_attrs(old_notes):
+ self.logger.warning("Selected notes in clip %s have extended attributes that were not returned." % (self.class_identifier))
+ old_ids = [n.note_id for n in old_notes]
+ if len(old_ids) == 0:
+ raise ValueError("No notes are selected for removal.")
+ clip.remove_notes_by_id(old_ids)
+
+ def clip_duplicate_all_notes(clip, params: Tuple[Any] = ()):
+ destination_time = -1
+ transposition = 0
+ if len(params) == 2:
+ destination_time, transposition = params
+ elif len(params) == 1:
+ destination_time = params[0]
+ elif len(params) > 2:
+ raise ValueError("Invalid number of arguments for /clip/duplicate/all_notes. 1-2 arguments must be passed.")
+ self.logger.info("Calling method for %s: duplicate_all_notes (params %s)" % (self.class_identifier, params))
+ notes = clip.get_all_notes_extended()
+ if len(notes) == 0:
+ raise ValueError("Clip has no notes to duplicate.")
+ from_time = min([n.start_time for n in notes])
+ end_time = max([n.start_time + n.duration for n in notes])
+ time_span = end_time - from_time
+ if destination_time == -1:
+ destination_time = end_time
+ clip.duplicate_region(from_time, time_span, destination_time, -1, transposition)
+
+ def clip_duplicate_region(clip, params: Tuple[Any] = ()):
+ self.logger.info("Calling method for %s: duplicate_region (params %s)" % (self.class_identifier, params))
+ transposition = 0
+ pitch = -1
+ destination_time = -1
+ if len(params) == 5:
+ from_time, time_span, destination_time, pitch, transposition = params
+ elif len(params) == 4:
+ from_time, time_span, destination_time, pitch = params
+ elif len(params) == 3:
+ from_time, time_span, destination_time = params
+ elif len(params) == 2:
+ from_time, time_span = params
+ else:
+ raise ValueError("Invalid number of arguments for /clip/duplicate/region. 2, 3, 4, or 5 arguments must be passed.")
+ if destination_time == -1:
+ destination_time = from_time + time_span
+ clip.duplicate_region(from_time, time_span, destination_time, pitch, transposition)
+
+ def clip_duplicate_selected_notes(clip, params: Tuple[Any] = ()):
+ if len(params) > 2:
+ raise ValueError("Invalid number of arguments for /clip/duplicate/selected_notes. 0-2 arguments must be passed.")
+ self.logger.info("Calling method for %s: duplicate_selected_notes (params %s)" % (self.class_identifier, params))
+ notes = clip.get_selected_notes_extended()
+ if len(notes) == 0:
+ raise ValueError("No notes are selected for duplication.")
+ note_ids = [n.note_id for n in notes]
+ clip.duplicate_notes_by_id(note_ids, *params)
+
+ def clip_select_notes(clip, params: Tuple[Any] = ()):
+ self.logger.info("Calling method for %s: select_notes (params %s)" % (self.class_identifier, params))
+ if len(params) == 4:
+ pitch_start, pitch_span, time_start, time_span = params
+ elif len(params) == 0:
+ clip.select_all_notes()
+ return
+ else:
+ raise ValueError("Invalid number of arguments for /clip/select/notes. Either 0 or 4 arguments must be passed.")
+ notes = clip.get_notes_extended(pitch_start, pitch_span, time_start, time_span)
+ note_ids = [n.note_id for n in notes]
+ if len(note_ids) == 0:
+ clip.deselect_all_notes()
+ return
+ clip.select_notes_by_id(note_ids)
+
+ def clip_deselect_notes(clip, params: Tuple[Any] = ()):
+ self.logger.info("Calling method for %s: deselect_notes (params %s)" % (self.class_identifier, params))
+ if len(params) == 4:
+ pitch_start, pitch_span, time_start, time_span = params
+ elif len(params) == 0:
+ clip.deselect_all_notes()
+ return
+ else:
+ raise ValueError("Invalid number of arguments for /clip/deselect/notes. Either 0 or 4 arguments must be passed.")
+ selected_notes = clip.get_selected_notes_extended()
+ selected_ids = {n.note_id for n in selected_notes}
+ deselect_notes = clip.get_notes_extended(pitch_start, pitch_span, time_start, time_span)
+ deselect_ids = {n.note_id for n in deselect_notes}
+ new_selection_ids = tuple(selected_ids - deselect_ids)
+ clip.deselect_all_notes()
+ if len(new_selection_ids) == 0:
+ return
+ clip.select_notes_by_id(new_selection_ids)
- self.osc_server.add_handler("/live/clip/get/notes", create_clip_callback(clip_get_notes))
- self.osc_server.add_handler("/live/clip/add/notes", create_clip_callback(clip_add_notes))
- self.osc_server.add_handler("/live/clip/remove/notes", create_clip_callback(clip_remove_notes))
+
+ # Not yet enabled, keeping for later
+ def clip_apply_note_modifications(clip, params: Tuple[Any] = ()):
+ self.logger.info("Calling method for %s: apply_note_modifications (params %s)" % (self.class_identifier, params))
+ updates = {}
+ note_ids = []
+ for pitch, start_time, duration, velocity, mute, note_id, probability, velocity_deviation, release_velocity in _chunk(params, 9):
+ updates[int(note_id)] = (pitch, start_time, duration, velocity, mute, probability, velocity_deviation, release_velocity)
+ note_ids.append(int(note_id))
+ notes = clip.get_notes_by_id(tuple(note_ids))
+ for note in notes:
+ note_id = int(note.note_id)
+ if note_id not in updates:
+ continue
+ pitch, start_time, duration, velocity, mute, probability, velocity_deviation, release_velocity = updates[note_id]
+ note.pitch = pitch
+ note.start_time = start_time
+ note.duration = duration
+ note.velocity = velocity
+ note.mute = mute
+ note.probability = probability
+ note.velocity_deviation = velocity_deviation
+ note.release_velocity = release_velocity
+ clip.apply_note_modifications(notes)
+
+
+
+ # Add Handlers
+ local_funcs = locals()
+ for method, spec in methods.items():
+ alias = spec.get("alias")
+ caller = spec.get("caller")
+ # Skip disabled entries
+ if not caller:
+ continue
+ # Custom methods
+ elif not alias and isinstance(caller, str):
+ target = local_funcs[caller]
+ self.osc_server.add_handler("/live/clip/%s" % method,
+ create_clip_callback(target))
+ self.osc_server.add_handler("/live/arrangement_clip/%s" % method,
+ create_arrangement_clip_callback(target))
+ # Standard methods and aliases
+ else:
+ target = caller if alias else method
+ self.osc_server.add_handler("/live/clip/%s" % method,
+ create_clip_callback(self._call_method, target))
+ self.osc_server.add_handler("/live/arrangement_clip/%s" % method,
+ create_arrangement_clip_callback(self._call_method, target))
+
+ for prop, spec in properties.items():
+ getter_func = spec.get("get")
+ if isinstance(getter_func, str):
+ getter = local_funcs[getter_func]
+ self.osc_server.add_handler("/live/clip/get/%s" % prop,
+ create_clip_callback(getter))
+ self.osc_server.add_handler("/live/arrangement_clip/get/%s" % prop,
+ create_arrangement_clip_callback(getter))
+ elif getter_func:
+ self.osc_server.add_handler("/live/clip/get/%s" % prop,
+ create_clip_callback(self._get_property, prop))
+ self.osc_server.add_handler("/live/arrangement_clip/get/%s" % prop,
+ create_arrangement_clip_callback(self._get_property, prop))
+
+ setter_func = spec.get("set")
+ if isinstance(setter_func, str):
+ setter = local_funcs[setter_func]
+ self.osc_server.add_handler("/live/clip/set/%s" % prop,
+ create_clip_callback(setter))
+ self.osc_server.add_handler("/live/arrangement_clip/set/%s" % prop,
+ create_arrangement_clip_callback(setter))
+ elif setter_func:
+ self.osc_server.add_handler("/live/clip/set/%s" % prop,
+ create_clip_callback(self._set_property, prop))
+ self.osc_server.add_handler("/live/arrangement_clip/set/%s" % prop,
+ create_arrangement_clip_callback(self._set_property, prop))
+
+ observable = spec.get("listen")
+ if isinstance(observable, str):
+ getter = "bang" if observable == "bang" else local_funcs[observable]
+ self.osc_server.add_handler("/live/clip/start_listen/%s" % prop,
+ create_clip_callback(self._start_listen, prop, pass_clip_index=True, getter=getter))
+ self.osc_server.add_handler("/live/clip/stop_listen/%s" % prop,
+ create_clip_callback(self._stop_listen, prop, pass_clip_index=True))
+ self.osc_server.add_handler("/live/arrangement_clip/start_listen/%s" % prop,
+ create_arrangement_clip_callback(self._start_listen, prop, pass_clip_index=True, getter=getter))
+ self.osc_server.add_handler("/live/arrangement_clip/stop_listen/%s" % prop,
+ create_arrangement_clip_callback(self._stop_listen, prop, pass_clip_index=True))
+ elif observable:
+ self.osc_server.add_handler("/live/clip/start_listen/%s" % prop,
+ create_clip_callback(self._start_listen, prop, pass_clip_index=True))
+ self.osc_server.add_handler("/live/clip/stop_listen/%s" % prop,
+ create_clip_callback(self._stop_listen, prop, pass_clip_index=True))
+ self.osc_server.add_handler("/live/arrangement_clip/start_listen/%s" % prop,
+ create_arrangement_clip_callback(self._start_listen, prop, pass_clip_index=True))
+ self.osc_server.add_handler("/live/arrangement_clip/stop_listen/%s" % prop,
+ create_arrangement_clip_callback(self._stop_listen, prop, pass_clip_index=True))
+
+ # Global Clips Handlers
+
def clips_filter_handler(params: Tuple):
# TODO: Pre-cache clip notes
if len(self._clip_notes_cache) == 0:
@@ -202,6 +625,9 @@ def clips_unfilter_handler(params: Tuple):
self.osc_server.add_handler("/live/clips/unfilter", clips_unfilter_handler)
+
+ # Helpers
+
def _build_clip_name_cache(self):
regex = "([_-])([A-G][A-G#b1-9-]*)$"
for track_index, track in enumerate(self.song.tracks):
@@ -218,3 +644,90 @@ def _build_clip_name_cache(self):
clip_notes_list = clip_notes_str.split("-")
clip_notes_list = [note_name_to_midi(name) for name in clip_notes_list]
self._clip_notes_cache[-1][-1] = clip_notes_list
+
+
+class ClipViewHandler(AbletonOSCHandler):
+ def __init__(self, manager):
+ super().__init__(manager)
+ self.class_identifier = "clip_view"
+
+ def init_api(self):
+ def create_clip_view_callback(func, *args, pass_clip_index=False):
+ def view_callback(params: Tuple[Any]) -> Tuple:
+ track_index, clip_index = int(params[0]), int(params[1])
+ track = self.song.tracks[track_index]
+ clip = track.clip_slots[clip_index].clip
+ view = clip.view
+ if pass_clip_index:
+ rv = func(view, *args, tuple(params[0:]))
+ else:
+ rv = func(view, *args, tuple(params[2:]))
+ if rv is not None:
+ return (track_index, clip_index, *rv)
+ return view_callback
+
+ def create_arrangement_clip_view_callback(func, *args, pass_clip_index=False):
+ def view_callback(params: Tuple[Any]) -> Tuple:
+ track_index, clip_index = int(params[0]), int(params[1])
+ track = self.song.tracks[track_index]
+ clip = track.arrangement_clips[clip_index]
+ view = clip.view
+ if pass_clip_index:
+ rv = func(view, *args, tuple(params[0:]))
+ else:
+ rv = func(view, *args, tuple(params[2:]))
+ if rv is not None:
+ return (track_index, clip_index, *rv)
+ return view_callback
+
+ methods = {
+ "show/loop": {"alias": 1, "caller": "show_loop"},
+ "show/envelope": {"alias": 1, "caller": "show_envelope"},
+ "hide/envelope": {"alias": 1, "caller": "hide_envelope"},
+ "select/envelope_parameter": {"alias": 0, "caller": None}, # TODO: Need to to target device parameter in envelope view
+ }
+
+ properties = {
+ "canonical_parent": {"get": 0, "set": 0, "listen": 0}, # TODO: Clip object: Not serializable
+ "grid_is_triplet": {"get": 1, "set": 1, "listen": 0},
+ "grid_quantization": {"get": 1, "set": 1, "listen": 0}, # {'8_bars': 1, '4_bars': 2, '2_bars': 3, 'bar': 4, 'half': 5, 'quarter': 6, 'eighth': 7, 'sixteenth': 8, 'thirtysecond': 9}
+ }
+
+ # Add Handlers
+ for method, spec in methods.items():
+ caller = spec.get("caller")
+ alias = spec.get("alias")
+ if not caller:
+ continue
+ else:
+ target = caller if alias else method
+ self.osc_server.add_handler("/live/clip/view/%s" % method,
+ create_clip_view_callback(self._call_method, target))
+ self.osc_server.add_handler("/live/arrangement_clip/view/%s" % method,
+ create_arrangement_clip_view_callback(self._call_method, target))
+
+ for prop, spec in properties.items():
+ getter_func = spec.get("get")
+ if getter_func:
+ self.osc_server.add_handler("/live/clip/view/get/%s" % prop,
+ create_clip_view_callback(self._get_property, prop))
+ self.osc_server.add_handler("/live/arrangement_clip/view/get/%s" % prop,
+ create_arrangement_clip_view_callback(self._get_property, prop))
+
+ setter_func = spec.get("set")
+ if setter_func:
+ self.osc_server.add_handler("/live/clip/view/set/%s" % prop,
+ create_clip_view_callback(self._set_property, prop))
+ self.osc_server.add_handler("/live/arrangement_clip/view/set/%s" % prop,
+ create_arrangement_clip_view_callback(self._set_property, prop))
+
+ listen_func = spec.get("listen")
+ if listen_func:
+ self.osc_server.add_handler("/live/clip/view/start_listen/%s" % prop,
+ create_clip_view_callback(self._start_listen, prop, pass_clip_index=True))
+ self.osc_server.add_handler("/live/clip/view/stop_listen/%s" % prop,
+ create_clip_view_callback(self._stop_listen, prop, pass_clip_index=True))
+ self.osc_server.add_handler("/live/arrangement_clip/view/start_listen/%s" % prop,
+ create_arrangement_clip_view_callback(self._start_listen, prop, pass_clip_index=True))
+ self.osc_server.add_handler("/live/arrangement_clip/view/stop_listen/%s" % prop,
+ create_arrangement_clip_view_callback(self._stop_listen, prop, pass_clip_index=True))
diff --git a/abletonosc/clip_slot.py b/abletonosc/clip_slot.py
index bfda31b..54c7e8f 100644
--- a/abletonosc/clip_slot.py
+++ b/abletonosc/clip_slot.py
@@ -7,61 +7,109 @@ def __init__(self, manager):
self.class_identifier = "clip_slot"
def init_api(self):
- def create_clip_slot_callback(func, *args, pass_clip_index=False):
+ def create_clip_slot_callback(func, *args, pass_clip_index=False, **kwargs):
def clip_slot_callback(params: Tuple[Any]):
track_index, clip_index = int(params[0]), int(params[1])
track = self.song.tracks[track_index]
clip_slot = track.clip_slots[clip_index]
if pass_clip_index:
- rv = func(clip_slot, *args, tuple(params[0:]))
+ rv = func(clip_slot, *args, tuple(params[0:]), **kwargs)
else:
- rv = func(clip_slot, *args, tuple(params[2:]))
+ rv = func(clip_slot, *args, tuple(params[2:]), **kwargs)
- self.logger.info(track_index, clip_index, rv)
+ self.logger.info("clip_slot %s,%s -> %s", track_index, clip_index, rv)
if rv is not None:
return (track_index, clip_index, *rv)
return clip_slot_callback
- methods = [
- "fire",
- "stop",
- "create_clip",
- "delete_clip"
- ]
- properties_r = [
- "has_clip",
- "controls_other_clips",
- "is_group_slot",
- "is_playing",
- "is_triggered",
- "playing_status",
- "will_record_on_start",
- ]
- properties_rw = [
- "has_stop_button"
- ]
+ methods = {
+ "fire": {"alias": 0, "caller": 1},
+ "stop": {"alias": 0, "caller": 1},
+ "create_clip": {"alias": 0, "caller": 1}, # Back-compat
+ "create/midi_clip": {"alias": 1, "caller": "create_clip"},
+ "create/audio_clip": {"alias": 1, "caller": "create_audio_clip"},
+ "delete_clip": {"alias": 0, "caller": 1}, # Back-compat
+ "delete/clip": {"alias": 1, "caller": "delete_clip"},
+ "duplicate_to": {"alias": 0, "caller": "clip_slot_duplicate_to"},
+ "set/fire_button": {"alias": 1, "caller": "set_fire_button_state"},
+ }
- for method in methods:
- self.osc_server.add_handler("/live/clip_slot/%s" % method,
- create_clip_slot_callback(self._call_method, method))
+ properties = {
+ # TODO: returns objects, needs serialization
+ # "canonical_parent": {"get": 0, "set": 0, "listen": 0},
+ # "clip": {"get": 0, "set": 0, "listen": 0},
- for prop in properties_r + properties_rw:
- self.osc_server.add_handler("/live/clip_slot/get/%s" % prop,
- create_clip_slot_callback(self._get_property, prop))
- self.osc_server.add_handler("/live/clip_slot/start_listen/%s" % prop,
- create_clip_slot_callback(self._start_listen, prop, pass_clip_index=True))
- self.osc_server.add_handler("/live/clip_slot/stop_listen/%s" % prop,
- create_clip_slot_callback(self._stop_listen, prop, pass_clip_index=True))
- for prop in properties_rw:
- self.osc_server.add_handler("/live/clip_slot/set/%s" % prop,
- create_clip_slot_callback(self._set_property, prop))
+ # All clip slots
+ "has_clip": {"get": 1, "set": 0, "listen": 1},
+ "has_stop_button": {"get": 1, "set": 1, "listen": 1},
+ "is_group_slot": {"get": 1, "set": 0, "listen": 0},
+ "is_playing": {"get": 1, "set": 0, "listen": 0},
+ "is_recording": {"get": 1, "set": 0, "listen": 0},
+ "is_triggered": {"get": 1, "set": 0, "listen": 1},
+ "will_record_on_start": {"get": 1, "set": 0, "listen": 0},
+
+ # Group Track slots only
+ "color": {"get": 1, "set": 0, "listen": 1},
+ "color_index": {"get": 1, "set": 0, "listen": 1},
+ "controls_other_clips": {"get": 1, "set": 0, "listen": 1},
+ "playing_status": {"get": 1, "set": 0, "listen": 1},
- def duplicate_clip_slot(clip_slot, args):
+
+ }
+
+ def clip_slot_duplicate_to(clip_slot, args):
target_track_index, target_clip_index = tuple(args)
track = self.song.tracks[target_track_index]
target_clip_slot = track.clip_slots[target_clip_index]
clip_slot.duplicate_clip_to(target_clip_slot)
- self.osc_server.add_handler("/live/clip_slot/duplicate_clip_to", create_clip_slot_callback(duplicate_clip_slot))
+ # Add Handlers
+ local_funcs = locals()
+ for method, spec in methods.items():
+ alias = spec.get("alias")
+ caller = spec.get("caller")
+ if not caller:
+ continue
+ if not alias and isinstance(caller, str):
+ caller = local_funcs[caller]
+ self.osc_server.add_handler("/live/clip_slot/%s" % method,
+ create_clip_slot_callback(caller))
+ else:
+ if not alias:
+ caller = method
+ self.osc_server.add_handler("/live/clip_slot/%s" % method,
+ create_clip_slot_callback(self._call_method, caller))
+
+ for prop, spec in properties.items():
+ getter_func = spec.get("get")
+ if isinstance(getter_func, str):
+ getter = local_funcs[getter_func]
+ self.osc_server.add_handler("/live/clip_slot/get/%s" % prop,
+ create_clip_slot_callback(getter))
+ elif getter_func:
+ self.osc_server.add_handler("/live/clip_slot/get/%s" % prop,
+ create_clip_slot_callback(self._get_property, prop))
+
+ setter_func = spec.get("set")
+ if isinstance(setter_func, str):
+ setter = local_funcs[setter_func]
+ self.osc_server.add_handler("/live/clip_slot/set/%s" % prop,
+ create_clip_slot_callback(setter))
+ elif setter_func:
+ self.osc_server.add_handler("/live/clip_slot/set/%s" % prop,
+ create_clip_slot_callback(self._set_property, prop))
+
+ observable = spec.get("listen")
+ if isinstance(observable, str):
+ getter = "bang" if observable == "bang" else local_funcs[observable]
+ self.osc_server.add_handler("/live/clip_slot/start_listen/%s" % prop,
+ create_clip_slot_callback(self._start_listen, prop, pass_clip_index=True, getter=getter))
+ self.osc_server.add_handler("/live/clip_slot/stop_listen/%s" % prop,
+ create_clip_slot_callback(self._stop_listen, prop, pass_clip_index=True))
+ elif observable:
+ self.osc_server.add_handler("/live/clip_slot/start_listen/%s" % prop,
+ create_clip_slot_callback(self._start_listen, prop, pass_clip_index=True))
+ self.osc_server.add_handler("/live/clip_slot/stop_listen/%s" % prop,
+ create_clip_slot_callback(self._stop_listen, prop, pass_clip_index=True))
diff --git a/abletonosc/handler.py b/abletonosc/handler.py
index 55d4913..2687871 100644
--- a/abletonosc/handler.py
+++ b/abletonosc/handler.py
@@ -61,8 +61,13 @@ def _start_listen(self, target, prop, params: Optional[Tuple] = (), getter = Non
def property_changed_callback():
if getter is None:
value = getattr(target, prop)
+ elif getter == "bang":
+ value = 1
else:
- value = getter(params)
+ try:
+ value = getter(target, params)
+ except TypeError:
+ value = getter(params)
if type(value) is not tuple:
value = (value,)
self.logger.info("Property %s changed of %s %s: %s" % (prop, self.class_identifier, str(params), value))
@@ -80,9 +85,10 @@ def property_changed_callback():
self.listener_functions[listener_key] = property_changed_callback
self.listener_objects[listener_key] = target
#--------------------------------------------------------------------------------
- # Immediately send the current value
+ # Immediately send the current value (skip for bang-only listeners)
#--------------------------------------------------------------------------------
- property_changed_callback()
+ if getter != "bang":
+ property_changed_callback()
def _stop_listen(self, target, prop, params: Optional[Tuple[Any]] = ()) -> None:
listener_key = (prop, tuple(params))
diff --git a/manager.py b/manager.py
index 94753c4..2dbcce3 100644
--- a/manager.py
+++ b/manager.py
@@ -94,6 +94,7 @@ def show_message_callback(params):
abletonosc.SongHandler(self),
abletonosc.ApplicationHandler(self),
abletonosc.ClipHandler(self),
+ abletonosc.ClipViewHandler(self),
abletonosc.ClipSlotHandler(self),
abletonosc.TrackHandler(self),
abletonosc.DeviceHandler(self),
@@ -155,4 +156,4 @@ def build_midi_map(self, midi_map_handle):
for channel, cc in self.midi_mappings.keys():
parameter = self.midi_mappings[(channel, cc)]
Live.MidiMap.map_midi_cc(midi_map_handle, parameter, channel, cc, Live.MidiMap.MapMode.absolute, 1)
- logger.debug("Mapped CC %d on channel %d to parameter %s" % (cc, channel, parameter.name))
\ No newline at end of file
+ logger.debug("Mapped CC %d on channel %d to parameter %s" % (cc, channel, parameter.name))
diff --git a/tests/__init__.py b/tests/__init__.py
index 5cec909..8cb2b4c 100644
--- a/tests/__init__.py
+++ b/tests/__init__.py
@@ -8,6 +8,10 @@
import sys
sys.path.append(".")
+from pathlib import Path
+import wave
+import struct
+
from ..client import AbletonOSCClient, TICK_DURATION
# Live tick is 100ms. Wait for this long plus a short additional buffer.
@@ -19,12 +23,45 @@ def client() -> AbletonOSCClient:
yield client
client.stop()
+@pytest.fixture(scope="function")
+def silent_audio_file() -> Path:
+ """
+ Create a silent WAV file in the tests directory for audio-clip tests.
+ """
+ path = Path(__file__).resolve().parent / "silent_8s.wav"
+ if not path.exists():
+ duration_s = 8.0
+ sample_rate = 48000
+ channels = 1
+ sample_width = 2 # 16-bit
+ total_frames = int(duration_s * sample_rate)
+ chunk_frames = 4096
+ silence_chunk = struct.pack(" 0:
+ frames = min(chunk_frames, frames_remaining)
+ wf.writeframes(silence_chunk[: frames * sample_width])
+ frames_remaining -= frames
+ yield path
+ remove_audio_file(path)
+
def wait_one_tick():
"""
Sleep for one Ableton Live tick (100ms).
"""
time.sleep(TICK_DURATION)
+def remove_audio_file(path: Path) -> None:
+ for target in (path, Path(str(path) + ".asd")):
+ try:
+ target.unlink()
+ except FileNotFoundError:
+ pass
+
c = AbletonOSCClient()
c.send_message("/live/api/reload")
c.stop()
diff --git a/tests/test_clip.py b/tests/test_clip.py
index 3f0479b..d18e52e 100644
--- a/tests/test_clip.py
+++ b/tests/test_clip.py
@@ -1,145 +1,658 @@
-from . import client, wait_one_tick, TICK_DURATION
-import pytest
-import random
-
-#--------------------------------------------------------------------------------
-# To test clips, initialise by creating an empty MIDI clip and recording
-# a short segment of audio.
-#
-# Note that for these tests to succeed, a default audio input device must be set.
-#--------------------------------------------------------------------------------
-@pytest.fixture(scope="module", autouse=True)
-def _create_test_clips(client):
- midi_track_id = 0
- midi_clip_id = 0
- client.send_message("/live/clip_slot/create_clip", [midi_track_id, midi_clip_id, 8.0])
-
- audio_track_id = 2
- audio_clip_id = 0
- client.send_message("/live/track/set/arm", [audio_track_id, True])
- client.send_message("/live/clip_slot/fire", [audio_track_id, audio_clip_id])
- wait_one_tick()
- client.send_message("/live/song/stop_playing")
- client.send_message("/live/song/stop_all_clips")
- client.send_message("/live/track/set/arm", [audio_track_id, False])
- yield
- client.send_message("/live/track/delete_clip", [audio_track_id, audio_clip_id])
- client.send_message("/live/track/delete_clip", [midi_track_id, midi_clip_id])
-
-#--------------------------------------------------------------------------------
-# Test clip properties
-#--------------------------------------------------------------------------------
-
-def _test_clip_property(client, track_id, clip_id, property, values):
- for value in values:
- client.send_message("/live/clip/set/%s" % property, (track_id, clip_id, value))
- wait_one_tick()
- assert client.query("/live/clip/get/%s" % property, (track_id, clip_id)) == (track_id, clip_id, value,)
-
-def test_clip_property_name(client):
- _test_clip_property(client, 0, 0, "name", ("Alpha", "Beta"))
-
-def test_clip_property_color(client):
- _test_clip_property(client, 0, 0, "color", (0x001AFF2F, 0x001A2F96))
-
-def test_clip_property_gain(client):
- _test_clip_property(client, 2, 0, "gain", (0.5, 1.0))
-
-def test_clip_property_pitch_coarse(client):
- _test_clip_property(client, 2, 0, "pitch_coarse", (4, 0))
-
-def test_clip_property_pitch_fine(client):
- _test_clip_property(client, 2, 0, "pitch_fine", (0.5, 0.0))
-
-def test_clip_add_remove_notes(client):
- assert client.query("/live/clip/get/notes", (0, 0)) == (0, 0)
-
- client.send_message("/live/clip/add/notes", (0, 0,
- 60, 0.0, 0.25, 64, False,
- 67, -0.25, 0.5, 32, False))
-
- # Should return all notes, including those before time = 0
- assert client.query("/live/clip/get/notes", (0, 0)) == (0, 0,
- 60, 0.0, 0.25, 64, False,
- 67, -0.25, 0.5, 32, False)
-
- client.send_message("/live/clip/add/notes", (0, 0,
- 72, 0.0, 0.25, 64, False,
- 60, 3.0, 0.5, 32, False))
-
- # Query between t in [0..2] and pitch in [60, 71]
- # Should only return a single note
- assert client.query("/live/clip/get/notes", (0, 0, 60, 11, 0, 2)) == (0, 0,
- 60, 0.0, 0.25, 64, False)
-
- client.send_message("/live/clip/remove/notes", (0, 0, 60, 11, 0, 2))
- assert client.query("/live/clip/get/notes", (0, 0)) == (0, 0,
- 60, 3.0, 0.5, 32, False,
- 67, -0.25, 0.5, 32, False,
- 72, 0.0, 0.25, 64, False)
- client.send_message("/live/clip/remove/notes", (0, 0))
- assert client.query("/live/clip/get/notes", (0, 0)) == (0, 0)
-
-def test_clip_add_many_notes(client):
+from client.client import TICK_DURATION
+from . import client, silent_audio_file, wait_one_tick
+
+
+# -----------------------------------------------------------------------------
+# Test playback controls
+# -----------------------------------------------------------------------------
+
+def test_clip_playback_controls(client):
+ track_id = 0
+ clip_id = 0
+
+ client.send_message("/live/clip_slot/create_clip", [track_id, clip_id, 8.0])
+
+ try:
+ # Add a couple notes so the clip has content
+ client.send_message("/live/clip/add/notes", (track_id, clip_id,
+ 60, 0.0, 0.5, 100, False,
+ 64, 0.5, 0.5, 100, False))
+ wait_one_tick()
+
+
+ # Fire
+ client.send_message("/live/clip/fire", (track_id, clip_id))
+ wait_one_tick()
+ is_playing = client.query("/live/clip/get/is_playing", (track_id, clip_id))[2]
+ assert bool(is_playing)
+
+ # Move playing position forward while playing
+ client.send_message("/live/clip/move/playing_pos", (track_id, clip_id, 4.0))
+ wait_one_tick()
+ playing_pos = client.query("/live/clip/get/playing_position", (track_id, clip_id))[2]
+ assert playing_pos >= 3.5
+
+ # Stop
+ client.send_message("/live/clip/stop", (track_id, clip_id))
+ wait_one_tick()
+ is_playing = client.query("/live/clip/get/is_playing", (track_id, clip_id))[2]
+ assert not bool(is_playing)
+
+ # Scrub / stop scrub (no assertions, just ensure no errors)
+ client.send_message("/live/clip/scrub", (track_id, clip_id, 0.25))
+ client.send_message("/live/clip/stop/scrub", (track_id, clip_id))
+
+ # Setup loop for duplicate_loop
+ client.send_message("/live/clip/set/looping", (track_id, clip_id, 1))
+ client.send_message("/live/clip/set/loop_start", (track_id, clip_id, 0.0))
+ client.send_message("/live/clip/set/loop_end", (track_id, clip_id, 1.0))
+ wait_one_tick()
+
+ # Duplicate loop via both endpoints
+ loop_end_before = client.query("/live/clip/get/loop_end", (track_id, clip_id))[2]
+ client.send_message("/live/clip/duplicate_loop", (track_id, clip_id))
+ wait_one_tick()
+ loop_end_after = client.query("/live/clip/get/loop_end", (track_id, clip_id))[2]
+ assert loop_end_after > loop_end_before
+
+ client.send_message("/live/clip/duplicate/loop", (track_id, clip_id))
+ wait_one_tick()
+ loop_end_after2 = client.query("/live/clip/get/loop_end", (track_id, clip_id))[2]
+ assert loop_end_after2 > loop_end_after
+
+ # Fire button state
+ client.send_message("/live/clip/set/fire_button", (track_id, clip_id, 1))
+ client.send_message("/live/clip/set/fire_button", (track_id, clip_id, 0))
+
+ # Quantize
+ client.send_message("/live/clip/quantize", (track_id, clip_id, 4, 1.0))
+ wait_one_tick()
+
+ # Stop
+ client.send_message("/live/clip/stop", (track_id, clip_id))
+ wait_one_tick()
+ is_playing = client.query("/live/clip/get/is_playing", (track_id, clip_id))[2]
+ assert not bool(is_playing)
+
+ finally:
+ client.send_message("/live/song/stop_playing")
+ client.send_message("/live/track/delete_clip", [track_id, clip_id])
+
+# -----------------------------------------------------------------------------
+# Test note functions on MIDI clips
+# -----------------------------------------------------------------------------
+
+def _parse_notes(rv):
+ values = rv[2:]
+ return [tuple(values[i:i + 5]) for i in range(0, len(values), 5)]
+
+def _notes(client, track_id, clip_id):
+ rv = client.query("/live/clip/get/notes", (track_id, clip_id))
+ return _parse_notes(rv)
+
+def _selected_notes(client, track_id, clip_id):
+ rv = client.query("/live/clip/get/selected_notes", (track_id, clip_id))
+ return _parse_notes(rv)
+
+def _note_set(notes):
+ return {(pitch, start_time) for pitch, start_time, _, _, _ in notes}
+
+def _assert_notes_exact(notes, expected):
+ assert _note_set(notes) == set(expected)
+
+def _has_note(notes, pitch, start_time, tol=1e-6):
+ return any(n_pitch == pitch and abs(n_start - start_time) < tol
+ for n_pitch, n_start, _, _, _ in notes)
+
+def _assert_notes_present(notes, expected):
+ for pitch, start_time in expected:
+ assert _has_note(notes, pitch, start_time), f"Missing note pitch={pitch} start={start_time}"
+
+
+def test_clip_note_methods(client):
"""
- Test adding large numbers of notes to a clip.
- Note that Ableton API's get_notes returns notes sorted by pitch, then time, so add notes
- in this same order.
+ Test 1 (Add/Get/Replace/Remove/Listen)
+
+ | P | 0.0 | 0.5 | 1.0 |
+ | --- | --- | --- | --- |
+ | 27 | | | G |
+ | 26 | | F | C |
+ | 25 | E | B | |
+ | 24 | A | | |
+ """
+ track_id = 0
+ clip_id = 0
+
+ client.send_message("/live/clip_slot/create_clip", [track_id, clip_id, 8.0])
+
+ try:
+ client.send_message("/live/clip/start_listen/notes", (track_id, clip_id))
+
+ # Initial empty state
+ rv = client.await_message("/live/clip/get/notes", timeout=TICK_DURATION * 2)
+ _assert_notes_exact(_parse_notes(rv), [])
+
+ # Add A,B,C
+ client.send_message("/live/clip/add/notes", (track_id, clip_id,
+ 24, 0.0, 0.5, 100, False, # A
+ 25, 0.5, 0.5, 100, False, # B
+ 26, 1.0, 0.5, 100, False)) # C
+ rv = client.await_message("/live/clip/get/notes", timeout=TICK_DURATION * 2)
+ _assert_notes_exact(_parse_notes(rv), [(24, 0.0), (25, 0.5), (26, 1.0)])
+
+ # Range query: expect A,B (end exclusive)
+ rv = client.query("/live/clip/get/notes", (track_id, clip_id, 24, 4, 0.0, 1.0))
+ _assert_notes_exact(_parse_notes(rv), [(24, 0.0), (25, 0.5)])
+
+ # Replace all with E,F,G
+ client.send_message("/live/clip/replace/notes", (track_id, clip_id,
+ 25, 0.0, 0.5, 100, False, # E
+ 26, 0.5, 0.5, 100, False, # F
+ 27, 1.0, 0.5, 100, False)) # G
+ rv = client.await_message("/live/clip/get/notes", timeout=TICK_DURATION * 2)
+ _assert_notes_exact(_parse_notes(rv), [(25, 0.0), (26, 0.5), (27, 1.0)])
+
+ # Replace range (0.0..0.5) with A only -> A,F,G
+ client.send_message("/live/clip/replace/notes", (track_id, clip_id,
+ 24, 4, 0.0, 0.5,
+ 24, 0.0, 0.5, 100, False))
+ rv = client.await_message("/live/clip/get/notes", timeout=TICK_DURATION * 2)
+ _assert_notes_exact(_parse_notes(rv), [(24, 0.0), (26, 0.5), (27, 1.0)])
+
+ # Remove range (0.5..1.0) -> remove F, leaving A,G
+ client.send_message("/live/clip/remove/notes", (track_id, clip_id, 24, 4, 0.5, 0.5))
+ rv = client.await_message("/live/clip/get/notes", timeout=TICK_DURATION * 2)
+ _assert_notes_exact(_parse_notes(rv), [(24, 0.0), (27, 1.0)])
+
+ # Remove all
+ client.send_message("/live/clip/remove/notes", (track_id, clip_id))
+ rv = client.await_message("/live/clip/get/notes", timeout=TICK_DURATION * 2)
+ _assert_notes_exact(_parse_notes(rv), [])
+
+ finally: # Cleanup
+ client.send_message("/live/clip/stop_listen/notes", (track_id, clip_id))
+ client.send_message("/live/track/delete_clip", [track_id, clip_id])
+
+
+def test_clip_note_selection(client):
+ """
+ Test 2 (Selection)
+
+ | P | 0.0 | 0.5 | 1.0 | 1.5 |
+ | --- | --- | --- | --- | --- |
+ | 27 | | | | D |
+ | 26 | | F | C | |
+ | 25 | E | B | | |
+ | 24 | A | | | |
+ """
+ track_id = 0
+ clip_id = 0
+
+ client.send_message("/live/clip_slot/create_clip", [track_id, clip_id, 8.0])
+
+ try:
+ # Add A,B,C,D
+ client.send_message("/live/clip/add/notes", (track_id, clip_id,
+ 24, 0.0, 0.5, 100, False, # A
+ 25, 0.5, 0.5, 100, False, # B
+ 26, 1.0, 0.5, 100, False, # C
+ 27, 1.5, 0.5, 100, False)) # D
+ wait_one_tick()
+ _assert_notes_exact(_notes(client, track_id, clip_id), [(24, 0.0), (25, 0.5), (26, 1.0), (27, 1.5)])
+
+ # Select A,B,C
+ client.send_message("/live/clip/select/notes", (track_id, clip_id, 24, 4, 0.0, 1.5))
+ wait_one_tick()
+ _assert_notes_exact(_selected_notes(client, track_id, clip_id), [(24, 0.0), (25, 0.5), (26, 1.0)])
+
+ # Deselect C (time_start=1.0, time_span=0.5)
+ client.send_message("/live/clip/deselect/notes", (track_id, clip_id, 24, 4, 1.0, 0.5))
+ wait_one_tick()
+ _assert_notes_exact(_selected_notes(client, track_id, clip_id), [(24, 0.0), (25, 0.5)])
+
+ # Replace selected with E,F (E=25@0.0, F=26@0.5)
+ client.send_message("/live/clip/replace/selected_notes", (track_id, clip_id,
+ 25, 0.0, 0.5, 100, False, # E
+ 26, 0.5, 0.5, 100, False)) # F
+ wait_one_tick()
+ _assert_notes_exact(_notes(client, track_id, clip_id), [(25, 0.0), (26, 0.5), (26, 1.0), (27, 1.5)])
+
+ # Select E,F (time_start=0.0, time_span=1.0)
+ client.send_message("/live/clip/select/notes", (track_id, clip_id, 24, 4, 0.0, 1.0))
+ wait_one_tick()
+ _assert_notes_exact(_selected_notes(client, track_id, clip_id), [(25, 0.0), (26, 0.5)])
+
+ # Remove selected notes
+ client.send_message("/live/clip/remove/selected_notes", (track_id, clip_id))
+ wait_one_tick()
+ _assert_notes_exact(_notes(client, track_id, clip_id), [(26, 1.0), (27, 1.5)])
+
+ # Select all, then clear selection
+ client.send_message("/live/clip/select/notes", (track_id, clip_id))
+ wait_one_tick()
+ _assert_notes_exact(_selected_notes(client, track_id, clip_id), [(26, 1.0), (27, 1.5)])
+ client.send_message("/live/clip/deselect/notes", (track_id, clip_id))
+ wait_one_tick()
+ _assert_notes_exact(_selected_notes(client, track_id, clip_id), [])
+
+ finally: # Cleanup
+ client.send_message("/live/track/delete_clip", [track_id, clip_id])
+
+
+def test_clip_note_duplication(client):
+ """
+ Test 3 (Duplication)
+
+ | P | 0.0 | 0.5 | 1.0 | 1.5 | 2.0 | 2.5 | 3.0 | 3.5 | 4.0 | 4.5 | 5.0 | 5.5 | 6.0 | 6.5 | 7.0 | 7.5 | 8.0 | 8.5 | 9.0 |
+ | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
+ | 27 | | 4 | | 4 | | | 4 | | 4 | 5 | | 6 | | 8 | 9 | | 10 | | |
+ | 26 | 4 | | 4 | | | 4 | | 4 | | | | | | | | | | | 11 |
+ | 25 | | 1 | | 2 | | | 3 | | 3 | 5 | | 6 | | 7 | 9 | | 10 | | |
+ | 24 | 1 | | 2 | | | 3 | | 3 | | | | | | | | | | | 11 |
+ """
+ track_id = 0
+ clip_id = 0
+
+ client.send_message("/live/clip_slot/create_clip", [track_id, clip_id, 10.0])
+
+ try:
+ # Test convert/note_number_to_name
+ rv = client.query("/live/clip/convert/note_number_to_name", (track_id, clip_id, 60))
+ assert isinstance(rv[2], str)
+ assert rv[2] != ""
+
+ # Step 1: add 2 notes (p24@0.0, p25@0.5), duration 0.5
+ client.send_message("/live/clip/add/notes", (track_id, clip_id,
+ 24, 0.0, 0.5, 100, False,
+ 25, 0.5, 0.5, 100, False))
+ wait_one_tick()
+ notes = _notes(client, track_id, clip_id)
+ assert len(notes) == 2
+ _assert_notes_present(notes, [(24, 0.0), (25, 0.5)])
+
+ # Step 2: duplicate all notes (no args)
+ client.send_message("/live/clip/duplicate/all_notes", (track_id, clip_id))
+ wait_one_tick()
+ notes = _notes(client, track_id, clip_id)
+ assert len(notes) == 4
+ _assert_notes_present(notes, [(24, 1.0), (25, 1.5)])
+
+ # Step 3: duplicate all notes to destination_time = 2.5
+ client.send_message("/live/clip/duplicate/all_notes", (track_id, clip_id, 2.5, 0))
+ wait_one_tick()
+ notes = _notes(client, track_id, clip_id)
+ assert len(notes) == 8
+ _assert_notes_present(notes, [(24, 2.5), (25, 3.0), (24, 3.5), (25, 4.0)])
+
+ # Step 4: duplicate all notes to destination_time = 0, transposition = 2
+ client.send_message("/live/clip/duplicate/all_notes", (track_id, clip_id, 0.0, 2))
+ wait_one_tick()
+ notes = _notes(client, track_id, clip_id)
+ assert len(notes) == 16
+ _assert_notes_present(notes, [
+ (26, 0.0), (27, 0.5), (26, 1.0), (27, 1.5),
+ (26, 2.5), (27, 3.0), (26, 3.5), (27, 4.0),
+ ])
+
+ # Step 5: duplicate region (from_time=4.0, time_span=0.5)
+ client.send_message("/live/clip/duplicate/region", (track_id, clip_id, 4.0, 0.5))
+ wait_one_tick()
+ notes = _notes(client, track_id, clip_id)
+ assert len(notes) == 18
+ _assert_notes_present(notes, [(25, 4.5), (27, 4.5)])
+
+ # Step 6: duplicate region (from_time=4.5, time_span=0.5, destination_time=5.5)
+ client.send_message("/live/clip/duplicate/region", (track_id, clip_id, 4.5, 0.5, 5.5))
+ wait_one_tick()
+ notes = _notes(client, track_id, clip_id)
+ assert len(notes) == 20
+ _assert_notes_present(notes, [(25, 5.5), (27, 5.5)])
+
+ # Step 7: duplicate region (from_time=5.5, time_span=0.5, destination_time=6.5, pitch=25)
+ client.send_message("/live/clip/duplicate/region", (track_id, clip_id, 5.5, 0.5, 6.5, 25))
+ wait_one_tick()
+ notes = _notes(client, track_id, clip_id)
+ assert len(notes) == 21
+ _assert_notes_present(notes, [(25, 6.5)])
+
+ # Step 8: duplicate region (from_time=5.5, time_span=0.5, destination_time=6.5, pitch=25, transposition=2)
+ client.send_message("/live/clip/duplicate/region", (track_id, clip_id, 5.5, 0.5, 6.5, 25, 2))
+ wait_one_tick()
+ notes = _notes(client, track_id, clip_id)
+ assert len(notes) == 22
+ _assert_notes_present(notes, [(27, 6.5)])
+
+ # Step 9: select last two (time 6.5..7.0, pitches 24..27) then duplicate selected (no args)
+ client.send_message("/live/clip/select/notes", (track_id, clip_id, 24, 4, 6.5, 0.5))
+ wait_one_tick()
+ client.send_message("/live/clip/duplicate/selected_notes", (track_id, clip_id))
+ wait_one_tick()
+ notes = _notes(client, track_id, clip_id)
+ assert len(notes) == 24
+ _assert_notes_present(notes, [(25, 7.0), (27, 7.0)])
+
+ # Step 10: select last two (time 7.0...7.5, pitches 24..27) then duplicate selected (destination_time = 8.0)
+ client.send_message("/live/clip/select/notes", (track_id, clip_id, 24, 4, 7.0, 0.5))
+ wait_one_tick()
+ client.send_message("/live/clip/duplicate/selected_notes", (track_id, clip_id, 8.0))
+ wait_one_tick()
+ notes = _notes(client, track_id, clip_id)
+ assert len(notes) == 26
+ _assert_notes_present(notes, [(25, 8.0), (27, 8.0)])
+
+ # Step 11: select last two (time 8.0...8.5, pitches 24..27) then duplicate selected (destination_time = 9.0, transposition = -1)
+ client.send_message("/live/clip/select/notes", (track_id, clip_id, 24, 4, 8.0, 0.5))
+ wait_one_tick()
+ client.send_message("/live/clip/duplicate/selected_notes", (track_id, clip_id, 9.0, -1))
+ wait_one_tick()
+ notes = _notes(client, track_id, clip_id)
+ assert len(notes) == 28
+ _assert_notes_present(notes, [(24, 9.0), (26, 9.0)])
+
+ finally: # Cleanup
+ client.send_message("/live/track/delete_clip", [track_id, clip_id])
+
+
+# By-ID flows - Not to be implemented yet.
+# /live/clip/get/notes_by_id (ids list)
+# /live/clip/replace/notes_by_id (ids list + 5-field list)
+# /live/clip/select/notes_by_id (ids list)
+# /live/clip/deselect/notes_by_id (ids list)
+# /live/clip/remove/notes_by_id (ids list)
+# /live/clip/modify/notes (9-field per note: includes note_id)
+# _delete_midi_clip
+
+
+# -----------------------------------------------------------------------------
+# Test warp and time conversion functions on audio clips
+# -----------------------------------------------------------------------------
+
+def test_clip_warp_markers(client, silent_audio_file):
"""
- random.seed(0)
- all_note_data = []
- pitch = 0
- for pitch_index in range(127):
- time = random.randrange(-32, 32) / 4
- duration = random.randrange(1, 4) / 4
- velocity = random.randrange(1, 128)
- # Create multiple instances of the same sequence, shifted in time.
- for timeshift in range(3):
- note = (pitch,
- time + (timeshift * 8),
- duration,
- velocity,
- False)
- all_note_data += note
- pitch += 1
- all_note_data = tuple(all_note_data)
-
- # Check clip is initially empty
- assert client.query("/live/clip/get/notes", (0, 0)) == (0, 0)
-
- # Populate clip and check return value
- client.send_message("/live/clip/add/notes", (0, 0) + all_note_data)
- assert client.query("/live/clip/get/notes", (0, 0)) == (0, 0) + all_note_data
-
- # Clear clip
- client.send_message("/live/clip/remove/notes", (0, 0))
-
-def test_clip_playing_position_listen(client):
- client.send_message("/live/clip/start_listen/playing_position", [0, 0])
- client.send_message("/live/clip/fire", [0, 0])
-
- rv = client.await_message("/live/clip/get/playing_position", TICK_DURATION * 2)
- assert rv == (0, 0, 0)
-
- rv = client.await_message("/live/clip/get/playing_position", TICK_DURATION * 2)
- assert rv[0] == 0
- assert rv[1] == 0
- assert rv[2] > 0
-
- client.send_message("/live/clip/stop_listen/playing_position", (0, 0))
-
-def test_clip_listen_lifecycle(client):
- client.send_message("/live/clip/set/name", [0, 0, "Alpha"])
- wait_one_tick()
- client.send_message("/live/clip/start_listen/name", [0, 0])
- assert client.await_message("/live/clip/get/name", TICK_DURATION * 2) == (0, 0, "Alpha")
- client.send_message("/live/clip/set/name", [0, 0, "Beta"])
- assert client.await_message("/live/clip/get/name", TICK_DURATION * 2) == (0, 0, "Beta")
-
- client.send_message("/live/clip_slot/delete_clip", [0, 0])
- client.send_message("/live/clip_slot/create_clip", [0, 0, 8.0])
- client.send_message("/live/clip/start_listen/name", [0, 0])
- assert client.await_message("/live/clip/get/name", TICK_DURATION * 2) == (0, 0, "")
- client.send_message("/live/clip/set/name", [0, 0, "Alpha"])
- assert client.await_message("/live/clip/get/name", TICK_DURATION * 2) == (0, 0, "Alpha")
- client.send_message("/live/clip/stop_listen/name", [0, 0])
\ No newline at end of file
+ Use the listener to confirm edits
+ Warp markers snap to samples, allow some small amount of variation (1E-4) for matching"""
+ track_id = 2
+ clip_id = 0
+ tol = 1e-4
+
+ def _parse_markers(rv):
+ values = rv[2:]
+ return [(values[i], values[i + 1]) for i in range(0, len(values), 2)]
+
+ def _assert_markers_exact(markers, expected):
+ assert len(markers) == len(expected)
+ for (beat, sample), (exp_beat, exp_sample) in zip(markers, expected):
+ assert abs(beat - exp_beat) < tol
+ assert abs(sample - exp_sample) < tol
+
+ client.send_message("/live/clip_slot/create/audio_clip", (2, 0, str(silent_audio_file)))
+
+ try:
+ client.send_message("/live/clip/start_listen/warp_markers", (track_id, clip_id))
+
+ # Initial markers
+ rv = client.query("/live/clip/get/warp_markers", (track_id, clip_id))
+ markers = _parse_markers(rv)
+ _assert_markers_exact(markers, [(0.0, 0.0), (16.0, 8.0)])
+
+ # Add marker at beat 8, sample 4
+ client.send_message("/live/clip/add/warp_marker", (track_id, clip_id, 8.0, 4.0))
+ rv = client.await_message("/live/clip/get/warp_markers", timeout=TICK_DURATION * 2)
+ markers = _parse_markers(rv)
+ _assert_markers_exact(markers, [(0.0, 0.0), (8.0, 4.0), (16.0, 8.0)])
+
+ # Add marker at beat 12 (sample inferred)
+ client.send_message("/live/clip/add/warp_marker", (track_id, clip_id, 12.0))
+ rv = client.await_message("/live/clip/get/warp_markers", timeout=TICK_DURATION * 2)
+ markers = _parse_markers(rv)
+ _assert_markers_exact(markers, [(0.0, 0.0), (8.0, 4.0), (12.0, 6.0), (16.0, 8.0)])
+
+ # Remove marker at beat 0
+ client.send_message("/live/clip/remove/warp_marker", (track_id, clip_id, 0.0))
+ rv = client.await_message("/live/clip/get/warp_markers", timeout=TICK_DURATION * 2)
+ markers = _parse_markers(rv)
+ _assert_markers_exact(markers, [(8.0, 4.0), (12.0, 6.0), (16.0, 8.0)])
+
+ # Move marker at beat 8 by -4 beats
+ client.send_message("/live/clip/move/warp_marker", (track_id, clip_id, 8.0, -4.0))
+ rv = client.await_message("/live/clip/get/warp_markers", timeout=TICK_DURATION * 2)
+ markers = _parse_markers(rv)
+ _assert_markers_exact(markers, [(4.0, 4.0), (12.0, 6.0), (16.0, 8.0)])
+
+ client.send_message("/live/clip/stop_listen/warp_markers", (track_id, clip_id))
+
+ finally:
+ client.send_message("/live/clip_slot/delete/clip", (2, 0))
+ wait_one_tick()
+
+def test_clip_convert_time(client, silent_audio_file):
+ track_id = 2
+ clip_id = 0
+ tol = 1e-4
+
+ def _assert_close(actual, expected):
+ assert abs(actual - expected) < tol
+
+ client.send_message("/live/clip_slot/create/audio_clip", (track_id, clip_id, str(silent_audio_file)))
+
+ try:
+ sample_rate = client.query("/live/clip/get/sample_rate", (track_id, clip_id))[2]
+ beats_val = 2.0
+ seconds_val = 1.0 # 2 beats @ 120 bpm
+ samples_val = sample_rate * seconds_val
+
+ # beats -> seconds
+ rv = client.query("/live/clip/convert/time", (track_id, clip_id, "beats", "seconds", beats_val))
+ _assert_close(rv[2], seconds_val)
+
+ # beats -> samples
+ rv = client.query("/live/clip/convert/time", (track_id, clip_id, "beats", "samples", beats_val))
+ _assert_close(rv[2], samples_val)
+
+ # seconds -> beats
+ rv = client.query("/live/clip/convert/time", (track_id, clip_id, "seconds", "beats", seconds_val))
+ _assert_close(rv[2], beats_val)
+
+ # seconds -> samples
+ rv = client.query("/live/clip/convert/time", (track_id, clip_id, "seconds", "samples", seconds_val))
+ _assert_close(rv[2], samples_val)
+
+ # samples -> seconds
+ rv = client.query("/live/clip/convert/time", (track_id, clip_id, "samples", "seconds", samples_val))
+ _assert_close(rv[2], seconds_val)
+
+ # samples -> beats
+ rv = client.query("/live/clip/convert/time", (track_id, clip_id, "samples", "beats", samples_val))
+ _assert_close(rv[2], beats_val)
+
+ finally:
+ client.send_message("/live/clip_slot/delete/clip", (track_id, clip_id))
+ wait_one_tick()
+
+
+
+# -----------------------------------------------------------------------------
+# Test Clip Properties
+# -----------------------------------------------------------------------------
+
+def test_clip_properties_midi(client):
+ track_id = 0
+ clip_id = 0
+
+ properties_midi = { # (get, set, listen, set_value)
+ "automation_envelopes": (0, 0, 0, None),
+ "canonical_parent": (0, 0, 0, None),
+ "color": (1, 1, 1, 1716118),
+ "color_index": (1, 1, 1, 40),
+ "end_marker": (1, 1, 1, 6),
+ "start_marker": (1, 1, 1, 2),
+ "start_time": (1, 0, 1, None),
+ "end_time": (1, 0, 1, None),
+ "groove": (0, 0, 0, None),
+ "has_envelopes": (1, 0, 1, None),
+ "has_groove": (1, 0, 0, None),
+ "is_arrangement_clip": (1, 0, 0, None),
+ "is_midi_clip": (1, 0, 0, None),
+ "is_audio_clip": (1, 0, 0, None),
+ "is_overdubbing": (1, 0, 1, None),
+ "is_playing": (1, 1, 0, 1),
+ "is_recording": (1, 0, 1, 0),
+ "is_session_clip": (1, 0, 0, 0),
+ "is_take_lane_clip": (1, 0, 0, 0),
+ "is_triggered": (1, 0, 0, 0),
+ "launch_mode": (1, 1, 1, 1),
+ "launch_quantization": (1, 1, 1, 1),
+ "legato": (1, 1, 1, 1),
+ "length": (1, 0, 0, None),
+ "loop_end": (1, 1, 1, 6),
+ "loop_jump": (0, 0, 0, None), # listener is bang, test elsewhere
+ "loop_start": (1, 1, 1, 2),
+ "looping": (1, 1, 1, 0),
+ "muted": (1, 1, 1, 1),
+ "name": (1, 1, 1, "new_name"),
+ "playing_position": (1, 0, 1, None),
+ "playing_status": (0, 0, 0, None), # listener is bang, test elsewhere
+ "position": (1, 1, 1, 0),
+ "signature_denominator": (1, 1, 1, 8),
+ "signature_numerator": (1, 1, 1, 6),
+ "velocity_amount": (1, 1, 1, 1),
+ "will_record_on_start": (1, 0, 0, None),
+ }
+
+ client.send_message("/live/clip_slot/create/midi_clip", (track_id, clip_id, 8))
+ client.send_message("/live/song/set/clip_trigger_quantization", (0))
+
+ try:
+ for prop, spec in properties_midi.items():
+ # Read/Write Properties
+ if spec[0] and spec[1]:
+ client.send_message(f"/live/clip/get/{prop}", (track_id, clip_id))
+ client.send_message(f"/live/clip/set/{prop}", (track_id, clip_id, spec[3]))
+ wait_one_tick()
+ assert client.query(f"/live/clip/get/{prop}", (track_id, clip_id)) == (track_id, clip_id, spec[3])
+
+ # Read Only Properties
+ if spec[0] and not spec[1]:
+ assert client.query(f"/live/clip/get/{prop}", (track_id, clip_id))[:2] == (track_id, clip_id)
+
+ # Listeners
+ if spec[2]:
+ client.send_message(f"/live/clip/start_listen/{prop}", (track_id, clip_id))
+ assert client.await_message(f"/live/clip/get/{prop}", TICK_DURATION * 2)[:2] == (track_id, clip_id)
+ client.send_message(f"/live/clip/stop_listen/{prop}", (track_id, clip_id))
+
+ finally:
+ client.send_message("/live/song/stop_playing")
+ client.send_message("/live/clip_slot/delete/clip", (track_id, clip_id))
+ wait_one_tick()
+
+def test_bang_listeners(client):
+ track_id = 0
+ clip_id = 0
+
+ client.send_message("/live/clip_slot/create/midi_clip", (track_id, clip_id, 8))
+ client.send_message("/live/song/set/clip_trigger_quantization", (0))
+
+ try:
+ # Playing status
+ client.send_message("/live/clip/set/end_marker", (track_id, clip_id, .5))
+ client.send_message("/live/clip/set/looping", (track_id, clip_id, 0))
+ wait_one_tick()
+
+ client.send_message("/live/clip/start_listen/playing_status", (track_id, clip_id))
+ client.send_message("/live/clip/fire", (track_id, clip_id))
+ assert client.await_message("/live/clip/get/playing_status", TICK_DURATION * 2) == (track_id, clip_id, 1)
+ client.send_message("/live/clip/stop_listen/playing_status", (track_id, clip_id))
+
+ # Loop Jump
+ client.send_message("/live/clip/set/looping", (track_id, clip_id, 1))
+ client.send_message("/live/clip/set/loop_end", (track_id, clip_id, .25))
+ wait_one_tick()
+
+ client.send_message("/live/clip/fire", (track_id, clip_id))
+ client.send_message("/live/clip/start_listen/loop_jump", (track_id, clip_id))
+ assert client.await_message("/live/clip/get/loop_jump", TICK_DURATION * 20) == (track_id, clip_id, 1)
+ client.send_message("/live/clip/stop_listen/loop_jump", (track_id, clip_id))
+
+ finally:
+ client.send_message("/live/song/stop_playing")
+ client.send_message("/live/clip_slot/delete/clip", (track_id, clip_id))
+ wait_one_tick()
+
+
+# -----------------------------------------------------------------------------
+# test_clip_audio_properties (Audio)
+# -----------------------------------------------------------------------------
+def test_clip_properties_audio(client, silent_audio_file):
+ track_id = 2
+ clip_id = 0
+ properties_audio = { # (get, set, listen, set_value)
+ "available_warp_modes": (1, 0, 0, None),
+ "file_path": (1, 0, 1, None),
+ "gain": (1, 1, 1, 1),
+ "gain_display_string": (1, 0, 0, None),
+ "pitch_coarse": (1, 1, 1, 1),
+ "pitch_fine": (1, 1, 1, 1),
+ "ram_mode": (1, 1, 1, 0),
+ "sample_length": (1, 0, 0, None),
+ "sample_rate": (1, 0, 0, None),
+ "warp_mode": (1, 1, 1, 1),
+ "warp_markers": (1, 0, 1, None),
+ "warping": (1, 1, 1, 0),
+ }
+
+ client.send_message("/live/clip_slot/create/audio_clip", (track_id, clip_id, str(silent_audio_file)))
+ client.send_message("/live/song/set/clip_trigger_quantization", (0))
+
+ try:
+ for prop, spec in properties_audio.items():
+ # Read/Write Properties
+ if spec[0] and spec[1]:
+ client.send_message(f"/live/clip/get/{prop}", (track_id, clip_id))
+ client.send_message(f"/live/clip/set/{prop}", (track_id, clip_id, spec[3]))
+ wait_one_tick()
+ assert client.query(f"/live/clip/get/{prop}", (track_id, clip_id)) == (track_id, clip_id, spec[3])
+
+ # Read Only Properties
+ if spec[0] and not spec[1]:
+ assert client.query(f"/live/clip/get/{prop}", (track_id, clip_id))[:2] == (track_id, clip_id)
+
+ # Listeners
+ if spec[2]:
+ client.send_message(f"/live/clip/start_listen/{prop}", (track_id, clip_id))
+ assert client.await_message(f"/live/clip/get/{prop}", TICK_DURATION * 2)[:2] == (track_id, clip_id)
+ client.send_message(f"/live/clip/stop_listen/{prop}", (track_id, clip_id))
+
+ finally:
+ client.send_message("/live/clip_slot/delete/clip", (track_id, clip_id))
+ wait_one_tick()
+
+
+# -----------------------------------------------------------------------------
+# Test Clip View
+# -----------------------------------------------------------------------------
+
+def test_clip_view_commands(client):
+ track_id = 0
+ clip_id = 0
+
+ client.send_message("/live/clip_slot/create_clip", [track_id, clip_id, 8.0])
+
+ try:
+ # Basic view actions (no observable state to assert)
+ client.send_message("/live/clip/view/show/loop", (track_id, clip_id))
+ client.send_message("/live/clip/view/show/envelope", (track_id, clip_id))
+ client.send_message("/live/clip/view/hide/envelope", (track_id, clip_id))
+
+ # Grid triplet
+ client.send_message("/live/clip/view/set/grid_is_triplet", (track_id, clip_id, 1))
+ wait_one_tick()
+ assert client.query("/live/clip/view/get/grid_is_triplet", (track_id, clip_id)) == (track_id, clip_id, 1)
+
+ # Grid quantization
+ client.send_message("/live/clip/view/set/grid_quantization", (track_id, clip_id, 7))
+ wait_one_tick()
+ assert client.query("/live/clip/view/get/grid_quantization", (track_id, clip_id)) == (track_id, clip_id, 7)
+
+ finally:
+ client.send_message("/live/track/delete_clip", [track_id, clip_id])
diff --git a/tests/test_clip_slot.py b/tests/test_clip_slot.py
index 4d518cf..bd2695e 100644
--- a/tests/test_clip_slot.py
+++ b/tests/test_clip_slot.py
@@ -1,32 +1,137 @@
-from . import client, wait_one_tick, TICK_DURATION
+from . import client, wait_one_tick, silent_audio_file, remove_audio_file, TICK_DURATION
+import time
-def test_clip_slot_has_clip(client):
- assert client.query("/live/clip_slot/get/has_clip", (0, 0)) == (0, 0, False)
- client.send_message("/live/clip_slot/create_clip", (0, 0, 4.0))
- assert client.query("/live/clip_slot/get/has_clip", (0, 0)) == (0, 0, True)
- client.send_message("/live/clip_slot/delete_clip", (0, 0))
+def test_clip_slot_create_clips(client, silent_audio_file):
+ try:
+ # MIDI Clip
+ assert client.query("/live/clip_slot/get/has_clip", (0, 0)) == (0, 0, False)
+ client.send_message("/live/clip_slot/create/midi_clip", (0, 0, 4.0))
+ wait_one_tick()
+ assert client.query("/live/clip_slot/get/has_clip", (0, 0)) == (0, 0, True)
+
+ # Audio Clip
+ assert client.query("/live/clip_slot/get/has_clip", (2, 0)) == (2, 0, False)
+ client.send_message("/live/clip_slot/create/audio_clip", (2, 0, str(silent_audio_file)))
+ wait_one_tick()
+ assert client.query("/live/clip_slot/get/has_clip", (2, 0)) == (2, 0, True)
+
+ finally:
+ client.send_message("/live/clip_slot/delete/clip", (0, 0))
+ client.send_message("/live/clip_slot/delete/clip", (2, 0))
+ remove_audio_file(silent_audio_file)
+ wait_one_tick()
+
+def test_clip_slot_create_clip_back_compat(client):
+ try:
+ # MIDI Clip
+ assert client.query("/live/clip_slot/get/has_clip", (0, 0)) == (0, 0, False)
+ client.send_message("/live/clip_slot/create_clip", (0, 0, 4.0))
+ wait_one_tick()
+ assert client.query("/live/clip_slot/get/has_clip", (0, 0)) == (0, 0, True)
+
+ finally:
+ client.send_message("/live/clip_slot/delete_clip", (0, 0))
+ wait_one_tick()
def test_clip_slot_duplicate(client):
- client.send_message("/live/clip_slot/create_clip", [0, 0, 4.0])
- client.send_message("/live/clip/get/notes", (0, 0))
- assert client.await_message("/live/clip/get/notes") == (0, 0)
+ try:
+ client.send_message("/live/clip_slot/create/midi_clip", [0, 0, 4.0])
+ client.send_message("/live/clip/get/notes", (0, 0))
+ assert client.await_message("/live/clip/get/notes") == (0, 0)
- client.send_message("/live/clip/add/notes", (0, 0,
- 60, 0.0, 0.25, 64, False))
+ client.send_message("/live/clip/add/notes", (0, 0,
+ 60, 0.0, 0.25, 64, False))
- client.send_message("/live/clip_slot/duplicate_clip_to", (0, 0, 0, 2))
- client.send_message("/live/clip/get/notes", (0, 2))
- assert client.await_message("/live/clip/get/notes") == (0, 2,
- 60, 0.0, 0.25, 64, False)
+ client.send_message("/live/clip_slot/duplicate_to", (0, 0, 0, 2))
+ client.send_message("/live/clip/get/notes", (0, 2))
+ assert client.await_message("/live/clip/get/notes") == (0, 2,
+ 60, 0.0, 0.25, 64, False)
+
+ finally:
+ client.send_message("/live/clip_slot/delete/clip", [0, 0])
+ client.send_message("/live/clip_slot/delete/clip", [0, 2])
+ wait_one_tick()
+
+def test_clip_slot_fire_stop(client):
+ client.send_message("/live/song/stop_playing")
+ client.send_message("/live/song/set/clip_trigger_quantization", (0))
+ try:
+ client.send_message("/live/clip_slot/create/midi_clip", [0, 0, 4.0])
+ # need delays since fire/stop doesn't trigger immediately
+ client.send_message("/live/clip_slot/fire", (0, 0))
+ wait_one_tick()
+ assert client.query("/live/clip/get/is_playing", (0, 0)) == (0, 0, True)
+ client.send_message("/live/clip_slot/stop", (0, 0))
+ wait_one_tick()
+ assert client.query("/live/clip/get/is_playing", (0, 0)) == (0, 0, False)
+ client.send_message("/live/clip_slot/set/fire_button", (0, 0, 1))
+ wait_one_tick()
+ assert client.query("/live/clip/get/is_playing", (0, 0)) == (0, 0, True)
+ client.send_message("/live/clip_slot/stop", (0, 0)) # set_fire_button_state to 0 does nothing.
+ finally:
+ client.send_message("/live/song/stop_playing")
+ client.send_message("/live/clip_slot/delete/clip", [0, 0])
+ wait_one_tick()
- client.send_message("/live/clip_slot/delete_clip", [0, 0])
- client.send_message("/live/clip_slot/delete_clip", [0, 2])
def test_clip_slot_property_listen(client):
- client.send_message("/live/clip_slot/start_listen/has_clip", (0, 0))
- assert client.await_message("/live/clip_slot/get/has_clip", TICK_DURATION * 2) == (0, 0, False)
- client.send_message("/live/clip_slot/create_clip", [0, 0, 4.0])
- assert client.await_message("/live/clip_slot/get/has_clip", TICK_DURATION * 2) == (0, 0, True)
- client.send_message("/live/clip_slot/delete_clip", [0, 0])
- assert client.await_message("/live/clip_slot/get/has_clip", TICK_DURATION * 2) == (0, 0, False)
- client.send_message("/live/clip_slot/stop_listen/has_clip", (0,))
\ No newline at end of file
+ try:
+ client.send_message("/live/clip_slot/start_listen/has_clip", (0, 0))
+ assert client.await_message("/live/clip_slot/get/has_clip", TICK_DURATION * 2) == (0, 0, False)
+ client.send_message("/live/clip_slot/create/midi_clip", [0, 0, 4.0])
+ assert client.await_message("/live/clip_slot/get/has_clip", TICK_DURATION * 2) == (0, 0, True)
+ client.send_message("/live/clip_slot/delete/clip", [0, 0])
+ assert client.await_message("/live/clip_slot/get/has_clip", TICK_DURATION * 2) == (0, 0, False)
+ client.send_message("/live/clip_slot/stop_listen/has_clip", (0, 0))
+ finally:
+ client.send_message("/live/clip_slot/delete/clip", [0, 0])
+ wait_one_tick()
+
+def _assert_clip_slot_get(client, prop, track_index=0, clip_index=0):
+ rv = client.query(f"/live/clip_slot/get/{prop}", (track_index, clip_index))
+ assert rv[0] == track_index and rv[1] == clip_index
+
+def test_clip_slot_endpoints(client):
+ client.send_message("/live/clip_slot/create/midi_clip", [0, 0, 4.0])
+
+ try:
+ # get read_only properties
+ for prop in [
+ "color",
+ "color_index",
+ "controls_other_clips",
+ "has_clip",
+ "has_stop_button",
+ "is_group_slot",
+ "is_playing",
+ "is_recording",
+ "is_triggered",
+ "playing_status",
+ "will_record_on_start",
+ ]:
+ _assert_clip_slot_get(client, prop, 0, 0)
+
+ # set has_stop_button (rw property)
+ client.send_message("/live/clip_slot/set/has_stop_button", (0, 0, 1))
+ assert client.query("/live/clip_slot/get/has_stop_button", (0, 0)) == (0, 0, True)
+ client.send_message("/live/clip_slot/set/has_stop_button", (0, 0, 0))
+ assert client.query("/live/clip_slot/get/has_stop_button", (0, 0)) == (0, 0, False)
+
+ # check listeners are created
+ for prop in [
+ "color",
+ "color_index",
+ "controls_other_clips",
+ "has_clip",
+ "has_stop_button",
+ "is_triggered",
+ "playing_status",
+ ]:
+ client.send_message(f"/live/clip_slot/start_listen/{prop}", (0, 0))
+ rv = client.await_message(f"/live/clip_slot/get/{prop}", TICK_DURATION * 2)
+ assert rv[0] == 0 and rv[1] == 0
+ client.send_message(f"/live/clip_slot/stop_listen/{prop}", (0, 0))
+
+ finally:
+ client.send_message("/live/clip_slot/delete/clip", [0, 0])
+ wait_one_tick()