From d5237b2e71848215116647a537681d41865326fe Mon Sep 17 00:00:00 2001 From: PhotonicVelocity Date: Wed, 28 Jan 2026 15:04:33 -0800 Subject: [PATCH 1/6] Add read-only clip properties --- README.md | 6 ++++++ abletonosc/clip.py | 18 ++++++++++++++---- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 083bb8d..6cf500a 100644 --- a/README.md +++ b/README.md @@ -374,11 +374,16 @@ Represents an audio or MIDI clip. Can be used to start/stop clips, and query/mod | /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/available_warp_modes | track_id, clip_id | track_id, clip_id, warp_mode, ... | Get available warp modes | +| /live/clip/get/has_envelopes | track_id, clip_id | track_id, clip_id, has_envelopes | Query whether clip has envelopes | +| /live/clip/get/is_arrangement_clip | track_id, clip_id | track_id, clip_id, is_arrangement_clip | Query whether clip is in arrangement view | | /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/is_session_clip | track_id, clip_id | track_id, clip_id, is_session_clip | Query whether clip is in session view | +| /live/clip/get/is_take_lane_clip | track_id, clip_id | track_id, clip_id, is_take_lane_clip | Query whether clip is a take lane clip | | /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 | @@ -389,6 +394,7 @@ Represents an audio or MIDI clip. Can be used to start/stop clips, and query/mod | /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/sample_rate | track_id, clip_id | track_id, clip_id, sample_rate | Get clip sample rate | | /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) | diff --git a/abletonosc/clip.py b/abletonosc/clip.py index ce29fa0..21a21f5 100644 --- a/abletonosc/clip.py +++ b/abletonosc/clip.py @@ -73,23 +73,27 @@ def clip_callback(params: Tuple[Any]) -> Tuple: "end_time", "file_path", "gain_display_string", + "has_envelopes", "has_groove", + "is_arrangement_clip", "is_midi_clip", "is_audio_clip", "is_overdubbing", "is_playing", "is_recording", + "is_session_clip", + "is_take_lane_clip", "is_triggered", "length", "playing_position", "sample_length", + "sample_rate", "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" + ## - "groove"; returns Groove object; needs custom serialization / API + ## - "warp_markers"; returns list of dicts; needs custom serialization + ## - "view"; returns ClipView object; needs custom serialization / API ] properties_rw = [ "color", @@ -167,6 +171,12 @@ def clip_remove_notes(clip, params: Tuple[Any] = ()): 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)) + def clip_get_available_warp_modes(clip, _): + return tuple(int(mode) for mode in clip.available_warp_modes) + + self.osc_server.add_handler("/live/clip/get/available_warp_modes", + create_clip_callback(clip_get_available_warp_modes)) + def clips_filter_handler(params: Tuple): # TODO: Pre-cache clip notes if len(self._clip_notes_cache) == 0: From 0414d935452ef960dde0a5cec284603cd5447ebc Mon Sep 17 00:00:00 2001 From: PhotonicVelocity Date: Wed, 28 Jan 2026 16:25:18 -0800 Subject: [PATCH 2/6] Add warp marker endpoints for clips --- README.md | 125 +++++++++++++++++++++++---------------------- abletonosc/clip.py | 28 +++++++++- tests/test_clip.py | 49 +++++++++++++++++- 3 files changed, 138 insertions(+), 64 deletions(-) diff --git a/README.md b/README.md index 083bb8d..4969ca0 100644 --- a/README.md +++ b/README.md @@ -350,66 +350,70 @@ Represents an audio or MIDI clip. Can be used to start/stop clips, and query/mod
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 | +| 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/warp_markers | track_id, clip_id | track_id, clip_id, beat_time_1, sample_time_1, beat_time_2, sample_time_2, ... | Get clip warp markers (pairs of beat_time, sample_time) | +| /live/clip/add_warp_marker | track_id, clip_id, beat_time, sample_time | | Add warp marker at beat time with sample time | +| /live/clip/move_warp_marker | track_id, clip_id, beat_time, beat_time_distance | | Move warp marker at beat time by beat_time_distance | +| /live/clip/remove_warp_marker | track_id, clip_id, beat_time | | Remove warp marker at beat time | +| /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 |
@@ -557,4 +561,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/clip.py b/abletonosc/clip.py index ce29fa0..c260efe 100644 --- a/abletonosc/clip.py +++ b/abletonosc/clip.py @@ -67,7 +67,9 @@ def clip_callback(params: Tuple[Any]) -> Tuple: "fire", "stop", "duplicate_loop", - "remove_notes_by_id" + "remove_notes_by_id", + "move_warp_marker", + "remove_warp_marker", ] properties_r = [ "end_time", @@ -88,7 +90,6 @@ def clip_callback(params: Tuple[Any]) -> Tuple: ## 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 = [ @@ -167,6 +168,29 @@ def clip_remove_notes(clip, params: Tuple[Any] = ()): 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)) + def clip_get_warp_markers(clip, _): + markers = clip.warp_markers + flat: list[float] = [] + for marker in markers: + flat.append(getattr(marker, "beat_time", None)) + flat.append(getattr(marker, "sample_time", None)) + return tuple(flat) + + self.osc_server.add_handler("/live/clip/get/warp_markers", + create_clip_callback(clip_get_warp_markers)) + + def clip_add_warp_marker(clip, params: Tuple[Any] = ()): + if len(params) == 2: + beat_time, sample_time = params + else: + raise ValueError("Invalid number of arguments for /clip/add_warp_marker. Pass beat_time, sample_time.") + + warp_marker = Live.Clip.WarpMarker(sample_time, beat_time) + clip.add_warp_marker(warp_marker) + + self.osc_server.add_handler("/live/clip/add_warp_marker", + create_clip_callback(clip_add_warp_marker)) + def clips_filter_handler(params: Tuple): # TODO: Pre-cache clip notes if len(self._clip_notes_cache) == 0: diff --git a/tests/test_clip.py b/tests/test_clip.py index 3f0479b..8ad1b16 100644 --- a/tests/test_clip.py +++ b/tests/test_clip.py @@ -142,4 +142,51 @@ def test_clip_listen_lifecycle(client): 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 + client.send_message("/live/clip/stop_listen/name", [0, 0]) + + +def _parse_warp_markers(rv): + # rv is (track_id, clip_id, beat_time_1, sample_time_1, ...) + values = rv[2:] + return [(values[i], values[i + 1]) for i in range(0, len(values), 2)] + + +def test_clip_warp_markers_add_move_remove(client): + track_id = 2 + clip_id = 0 + + rv = client.query("/live/clip/get/warp_markers", (track_id, clip_id)) + markers = _parse_warp_markers(rv) + existing_beats = {beat for beat, _ in markers} + + # The fixture records a very short clip (~0.075s). At 120 BPM, 0.075s = 0.15 beats. + # Keep beat_time/sample_time small so they stay in range. + beat_time = 0.05 + while beat_time in existing_beats: + beat_time += 0.05 + + sample_time = 0.025 + client.send_message("/live/clip/add_warp_marker", (track_id, clip_id, beat_time, sample_time)) + wait_one_tick() + + rv = client.query("/live/clip/get/warp_markers", (track_id, clip_id)) + markers = _parse_warp_markers(rv) + assert any(abs(beat - beat_time) < 1e-6 and abs(sample - sample_time) < 1e-6 + for beat, sample in markers) + + # Move marker by +0.05 beat + client.send_message("/live/clip/move_warp_marker", (track_id, clip_id, beat_time, 0.05)) + wait_one_tick() + + rv = client.query("/live/clip/get/warp_markers", (track_id, clip_id)) + markers = _parse_warp_markers(rv) + assert any(abs(beat - (beat_time + 0.05)) < 1e-6 and abs(sample - sample_time) < 1e-6 + for beat, sample in markers) + + # Remove moved marker + client.send_message("/live/clip/remove_warp_marker", (track_id, clip_id, beat_time + 0.05)) + wait_one_tick() + + rv = client.query("/live/clip/get/warp_markers", (track_id, clip_id)) + markers = _parse_warp_markers(rv) + assert not any(abs(beat - (beat_time + 0.05)) < 1e-6 for beat, _ in markers) From 0b6c6c4b0742400777addfe64c5fdcd3a34c1e0e Mon Sep 17 00:00:00 2001 From: PhotonicVelocity Date: Wed, 28 Jan 2026 16:37:41 -0800 Subject: [PATCH 3/6] Infer sample_time when adding warp marker with beat_time only --- README.md | 2 +- abletonosc/clip.py | 27 +++++++++++++++++++++++++-- tests/test_clip.py | 16 ++++++++++++++++ 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 4969ca0..dc8c818 100644 --- a/README.md +++ b/README.md @@ -380,7 +380,7 @@ Represents an audio or MIDI clip. Can be used to start/stop clips, and query/mod | /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/warp_markers | track_id, clip_id | track_id, clip_id, beat_time_1, sample_time_1, beat_time_2, sample_time_2, ... | Get clip warp markers (pairs of beat_time, sample_time) | -| /live/clip/add_warp_marker | track_id, clip_id, beat_time, sample_time | | Add warp marker at beat time with sample time | +| /live/clip/add_warp_marker | track_id, clip_id, beat_time [, sample_time] | | Add warp marker at beat time; if sample_time omitted, it is inferred from existing warp markers | | /live/clip/move_warp_marker | track_id, clip_id, beat_time, beat_time_distance | | Move warp marker at beat time by beat_time_distance | | /live/clip/remove_warp_marker | track_id, clip_id, beat_time | | Remove warp marker at beat time | | /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 | diff --git a/abletonosc/clip.py b/abletonosc/clip.py index c260efe..bb7981b 100644 --- a/abletonosc/clip.py +++ b/abletonosc/clip.py @@ -180,10 +180,33 @@ def clip_get_warp_markers(clip, _): create_clip_callback(clip_get_warp_markers)) def clip_add_warp_marker(clip, params: Tuple[Any] = ()): - if len(params) == 2: + 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, sample_time.") + raise ValueError("Invalid number of arguments for /clip/add_warp_marker. Pass beat_time or beat_time, sample_time.") + + if sample_time is None: + markers = [(m.beat_time, m.sample_time) for m in clip.warp_markers] + if not markers: + raise ValueError("No warp markers available to infer sample_time.") + markers.sort(key=lambda m: m[0]) + if beat_time <= markers[0][0]: + sample_time = markers[0][1] + elif beat_time >= markers[-1][0]: + sample_time = markers[-1][1] + else: + for i in range(len(markers) - 1): + beat_a, sample_a = markers[i] + beat_b, sample_b = markers[i + 1] + if beat_a <= beat_time <= beat_b: + t = (beat_time - beat_a) / (beat_b - beat_a) + sample_time = sample_a + t * (sample_b - sample_a) + break + if sample_time is None: + raise ValueError("Unable to infer sample_time from existing warp markers.") warp_marker = Live.Clip.WarpMarker(sample_time, beat_time) clip.add_warp_marker(warp_marker) diff --git a/tests/test_clip.py b/tests/test_clip.py index 8ad1b16..b1f884a 100644 --- a/tests/test_clip.py +++ b/tests/test_clip.py @@ -190,3 +190,19 @@ def test_clip_warp_markers_add_move_remove(client): rv = client.query("/live/clip/get/warp_markers", (track_id, clip_id)) markers = _parse_warp_markers(rv) assert not any(abs(beat - (beat_time + 0.05)) < 1e-6 for beat, _ in markers) + + # Add another marker using beat_time only (sample_time inferred) + # Choose midpoint between the first two existing markers. + markers_sorted = sorted(markers, key=lambda m: m[0]) + if len(markers_sorted) < 2: + pytest.skip("Not enough warp markers to infer sample_time.") + beat_time_only = (markers_sorted[0][0] + markers_sorted[1][0]) / 2.0 + before_count = len(markers_sorted) + + client.send_message("/live/clip/add_warp_marker", (track_id, clip_id, beat_time_only)) + wait_one_tick() + + rv = client.query("/live/clip/get/warp_markers", (track_id, clip_id)) + markers = _parse_warp_markers(rv) + print(markers) + assert len(markers) == before_count + 1 or any(abs(beat - beat_time_only) < 1e-2 for beat, _ in markers) From b3d09ee02b1be036776bab17e047954bdb5ba746 Mon Sep 17 00:00:00 2001 From: PhotonicVelocity Date: Wed, 28 Jan 2026 17:19:19 -0800 Subject: [PATCH 4/6] improved interpolation logic to handle insertion outside of marker range --- README.md | 4 ++-- abletonosc/clip.py | 17 +++++++++++++---- tests/test_clip.py | 12 ++++-------- 3 files changed, 19 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index dc8c818..32c5009 100644 --- a/README.md +++ b/README.md @@ -379,8 +379,8 @@ Represents an audio or MIDI clip. Can be used to start/stop clips, and query/mod | /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/warp_markers | track_id, clip_id | track_id, clip_id, beat_time_1, sample_time_1, beat_time_2, sample_time_2, ... | Get clip warp markers (pairs of beat_time, sample_time) | -| /live/clip/add_warp_marker | track_id, clip_id, beat_time [, sample_time] | | Add warp marker at beat time; if sample_time omitted, it is inferred from existing warp markers | +| /live/clip/get/warp_markers | track_id, clip_id | track_id, clip_id, beat_time_1, sample_time_1, beat_time_2, sample_time_2, ... | Get clip warp markers (pairs of beat_time, sample_time). Live includes a hidden marker ~1/32 beat after the last marker used to determine tempo. | +| /live/clip/add_warp_marker | track_id, clip_id, beat_time [, sample_time] | | Add warp marker at beat time; if sample_time omitted, it is inferred from existing warp markers | | /live/clip/move_warp_marker | track_id, clip_id, beat_time, beat_time_distance | | Move warp marker at beat time by beat_time_distance | | /live/clip/remove_warp_marker | track_id, clip_id, beat_time | | Remove warp marker at beat time | | /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 | diff --git a/abletonosc/clip.py b/abletonosc/clip.py index bb7981b..21f3c81 100644 --- a/abletonosc/clip.py +++ b/abletonosc/clip.py @@ -194,17 +194,26 @@ def clip_add_warp_marker(clip, params: Tuple[Any] = ()): raise ValueError("No warp markers available to infer sample_time.") markers.sort(key=lambda m: m[0]) if beat_time <= markers[0][0]: - sample_time = markers[0][1] + # Extrapolate using the first two markers when before the first marker + beat_a, sample_a = markers[0] + beat_b, sample_b = markers[1] if len(markers) > 1 else markers[0] elif beat_time >= markers[-1][0]: - sample_time = markers[-1][1] + # Extrapolate using the last two markers when after the last marker + beat_a, sample_a = markers[-2] if len(markers) > 1 else markers[-1] + beat_b, sample_b = markers[-1] else: + beat_a = sample_a = beat_b = sample_b = None for i in range(len(markers) - 1): beat_a, sample_a = markers[i] beat_b, sample_b = markers[i + 1] if beat_a <= beat_time <= beat_b: - t = (beat_time - beat_a) / (beat_b - beat_a) - sample_time = sample_a + t * (sample_b - sample_a) break + if beat_a is not None and beat_b is not None: + if beat_b == beat_a: + sample_time = sample_a + else: + t = (beat_time - beat_a) / (beat_b - beat_a) + sample_time = sample_a + t * (sample_b - sample_a) if sample_time is None: raise ValueError("Unable to infer sample_time from existing warp markers.") diff --git a/tests/test_clip.py b/tests/test_clip.py index b1f884a..2bd543d 100644 --- a/tests/test_clip.py +++ b/tests/test_clip.py @@ -192,17 +192,13 @@ def test_clip_warp_markers_add_move_remove(client): assert not any(abs(beat - (beat_time + 0.05)) < 1e-6 for beat, _ in markers) # Add another marker using beat_time only (sample_time inferred) - # Choose midpoint between the first two existing markers. - markers_sorted = sorted(markers, key=lambda m: m[0]) - if len(markers_sorted) < 2: - pytest.skip("Not enough warp markers to infer sample_time.") - beat_time_only = (markers_sorted[0][0] + markers_sorted[1][0]) / 2.0 - before_count = len(markers_sorted) + beat_time_only = 0.05 + while beat_time_only in existing_beats: + beat_time_only += 0.05 client.send_message("/live/clip/add_warp_marker", (track_id, clip_id, beat_time_only)) wait_one_tick() rv = client.query("/live/clip/get/warp_markers", (track_id, clip_id)) markers = _parse_warp_markers(rv) - print(markers) - assert len(markers) == before_count + 1 or any(abs(beat - beat_time_only) < 1e-2 for beat, _ in markers) + assert any(abs(beat - beat_time_only) < 1e-2 for beat, _ in markers) From d376b00c6fd32c92b37500580e7e87fe01833c78 Mon Sep 17 00:00:00 2001 From: PhotonicVelocity Date: Wed, 28 Jan 2026 19:19:05 -0800 Subject: [PATCH 5/6] add support for arrangement_clip --- README.md | 10 ++++++++-- abletonosc/clip.py | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 083bb8d..f855ab2 100644 --- a/README.md +++ b/README.md @@ -345,7 +345,7 @@ 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 @@ -415,6 +415,13 @@ Represents an audio or MIDI clip. Can be used to start/stop clips, and query/mod --- +## Arrangement Clip API + +Represents an audio or MIDI clip in Arrangement view. Endpoints mirror `/live/clip/*` but use the +`/live/arrangement_clip/*` namespace and index into `track.arrangement_clips`. + +--- + ## 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 +564,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/clip.py b/abletonosc/clip.py index ce29fa0..fff52f8 100644 --- a/abletonosc/clip.py +++ b/abletonosc/clip.py @@ -63,6 +63,25 @@ def clip_callback(params: Tuple[Any]) -> Tuple: return clip_callback + def create_arrangement_clip_callback(func, *args, pass_clip_index=False): + """ + 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:])) + else: + rv = func(clip, *args, tuple(params[2:])) + + if rv is not None: + return (track_index, clip_index, *rv) + + return clip_callback + methods = [ "fire", "stop", @@ -117,6 +136,8 @@ def clip_callback(params: Tuple[Any]) -> Tuple: for method in methods: self.osc_server.add_handler("/live/clip/%s" % method, create_clip_callback(self._call_method, method)) + self.osc_server.add_handler("/live/arrangement_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, @@ -125,9 +146,18 @@ def clip_callback(params: Tuple[Any]) -> Tuple: 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/get/%s" % prop, + create_arrangement_clip_callback(self._get_property, prop)) + 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)) + for prop in properties_rw: 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)) def clip_get_notes(clip, params: Tuple[Any] = ()): if len(params) == 4: @@ -164,8 +194,15 @@ def clip_remove_notes(clip, params: Tuple[Any] = ()): clip.remove_notes_extended(pitch_start, pitch_span, time_start, time_span) self.osc_server.add_handler("/live/clip/get/notes", create_clip_callback(clip_get_notes)) + self.osc_server.add_handler("/live/arrangement_clip/get/notes", + create_arrangement_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/arrangement_clip/add/notes", + create_arrangement_clip_callback(clip_add_notes)) self.osc_server.add_handler("/live/clip/remove/notes", create_clip_callback(clip_remove_notes)) + self.osc_server.add_handler("/live/arrangement_clip/remove/notes", + create_arrangement_clip_callback(clip_remove_notes)) + def clips_filter_handler(params: Tuple): # TODO: Pre-cache clip notes From 544fd222d76af1dc823a90d97b5ef18daa3474fc Mon Sep 17 00:00:00 2001 From: PhotonicVelocity Date: Wed, 28 Jan 2026 19:32:19 -0800 Subject: [PATCH 6/6] add support for warp markers in arrangement_clips --- abletonosc/clip.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/abletonosc/clip.py b/abletonosc/clip.py index e5156cc..72a6fa0 100644 --- a/abletonosc/clip.py +++ b/abletonosc/clip.py @@ -207,7 +207,7 @@ def clip_remove_notes(clip, params: Tuple[Any] = ()): create_arrangement_clip_callback(clip_add_notes)) self.osc_server.add_handler("/live/clip/remove/notes", create_clip_callback(clip_remove_notes)) self.osc_server.add_handler("/live/arrangement_clip/remove/notes", - create_arrangement_clip_callback(clip_remove_notes)) + create_arrangement_clip_callback(clip_remove_notes)) def clip_get_warp_markers(clip, _): markers = clip.warp_markers @@ -219,6 +219,8 @@ def clip_get_warp_markers(clip, _): self.osc_server.add_handler("/live/clip/get/warp_markers", create_clip_callback(clip_get_warp_markers)) + self.osc_server.add_handler("/live/arrangement_clip/get/warp_markers", + create_arrangement_clip_callback(clip_get_warp_markers)) def clip_add_warp_marker(clip, params: Tuple[Any] = ()): if len(params) == 1: @@ -263,6 +265,8 @@ def clip_add_warp_marker(clip, params: Tuple[Any] = ()): self.osc_server.add_handler("/live/clip/add_warp_marker", create_clip_callback(clip_add_warp_marker)) + self.osc_server.add_handler("/live/arrangement_clip/add_warp_marker", + create_arrangement_clip_callback(clip_add_warp_marker)) def clip_get_available_warp_modes(clip, _): @@ -270,6 +274,9 @@ def clip_get_available_warp_modes(clip, _): self.osc_server.add_handler("/live/clip/get/available_warp_modes", create_clip_callback(clip_get_available_warp_modes)) + self.osc_server.add_handler("/live/arrangement_clip/get/available_warp_modes", + create_arrangement_clip_callback(clip_get_available_warp_modes)) + def clips_filter_handler(params: Tuple): # TODO: Pre-cache clip notes