From 0414d935452ef960dde0a5cec284603cd5447ebc Mon Sep 17 00:00:00 2001 From: PhotonicVelocity Date: Wed, 28 Jan 2026 16:25:18 -0800 Subject: [PATCH 1/3] 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 2/3] 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 3/3] 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)