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()