From 4c4d60e837f598cff903cf3cee1a85cd50b5d0a9 Mon Sep 17 00:00:00 2001 From: PhotonicVelocity Date: Wed, 28 Jan 2026 13:04:41 -0800 Subject: [PATCH 1/6] Add create_audio_clip for clip slots --- README.md | 1 + abletonosc/clip_slot.py | 1 + 2 files changed, 2 insertions(+) diff --git a/README.md b/README.md index 083bb8d..47318b7 100644 --- a/README.md +++ b/README.md @@ -333,6 +333,7 @@ A Clip Slot represents a container for a clip. It is used to create and delete c |:------------------------------------|:---------------------------------------------------------------|:-----------------------------------------|:------------------------------------------------| | /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/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/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 | diff --git a/abletonosc/clip_slot.py b/abletonosc/clip_slot.py index bfda31b..7bf6783 100644 --- a/abletonosc/clip_slot.py +++ b/abletonosc/clip_slot.py @@ -28,6 +28,7 @@ def clip_slot_callback(params: Tuple[Any]): "fire", "stop", "create_clip", + "create_audio_clip", "delete_clip" ] properties_r = [ From d8c8d794767409429afb8e8d084045c276305dd9 Mon Sep 17 00:00:00 2001 From: PhotonicVelocity Date: Thu, 29 Jan 2026 12:33:29 -0800 Subject: [PATCH 2/6] Add clip slot properties and improve property management --- README.md | 44 +++++++++++++++++++++++++++++++--------- abletonosc/clip_slot.py | 45 +++++++++++++++++++++++------------------ 2 files changed, 59 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 47318b7..cf983b9 100644 --- a/README.md +++ b/README.md @@ -329,16 +329,40 @@ 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/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/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_clip | track_index, clip_index, length | | Create a 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_clip_to | track_index, clip_index, target_track_index, target_clip_index | | Duplicate the clip to an empty target clip slot | +| /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/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/set/has_stop_button | track_index, clip_index, has_stop_button | | Add or remove stop button (1=on, 0=off) |
diff --git a/abletonosc/clip_slot.py b/abletonosc/clip_slot.py index 7bf6783..4b64f30 100644 --- a/abletonosc/clip_slot.py +++ b/abletonosc/clip_slot.py @@ -29,35 +29,40 @@ def clip_slot_callback(params: Tuple[Any]): "stop", "create_clip", "create_audio_clip", - "delete_clip" - ] - properties_r = [ - "has_clip", - "controls_other_clips", - "is_group_slot", - "is_playing", - "is_triggered", - "playing_status", - "will_record_on_start", + "create_audio_clip", + "delete_clip", + # "duplicate_clip_slot", # Uses custom handler + "set_fire_button_state", ] - properties_rw = [ - "has_stop_button" + properties = [ # (name, writeable, observable) + ("color", 0, 1), # Only for Group Track slots + ("color_index", 0, 1), # Only for Group Track slots + ("controls_other_clips", 0, 1), # Only for Group Track slots + ("has_clip", 0, 1), + ("has_stop_button", 1, 1), + ("is_group_slot", 0, 0), + ("is_playing", 0, 0), + ("is_recording", 0, 0), + ("is_triggered", 0, 1), + ("playing_status", 0, 1), # Only for Group Track slots + ("will_record_on_start", 0, 0), ] for method in methods: self.osc_server.add_handler("/live/clip_slot/%s" % method, create_clip_slot_callback(self._call_method, method)) - for prop in properties_r + properties_rw: + for prop, writeable, observable in properties: 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)) + if writeable: + self.osc_server.add_handler("/live/clip_slot/set/%s" % prop, + create_clip_slot_callback(self._set_property, prop)) + if 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)) def duplicate_clip_slot(clip_slot, args): target_track_index, target_clip_index = tuple(args) From 351d750a1606fb7923fe2b299fc5eec6ca1b3f7f Mon Sep 17 00:00:00 2001 From: PhotonicVelocity Date: Thu, 29 Jan 2026 14:37:28 -0800 Subject: [PATCH 3/6] added tests for all properties --- abletonosc/clip_slot.py | 1 - tests/test_clip_slot.py | 65 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/abletonosc/clip_slot.py b/abletonosc/clip_slot.py index 4b64f30..8b452f6 100644 --- a/abletonosc/clip_slot.py +++ b/abletonosc/clip_slot.py @@ -29,7 +29,6 @@ def clip_slot_callback(params: Tuple[Any]): "stop", "create_clip", "create_audio_clip", - "create_audio_clip", "delete_clip", # "duplicate_clip_slot", # Uses custom handler "set_fire_button_state", diff --git a/tests/test_clip_slot.py b/tests/test_clip_slot.py index 4d518cf..30f32e5 100644 --- a/tests/test_clip_slot.py +++ b/tests/test_clip_slot.py @@ -1,4 +1,5 @@ from . import client, wait_one_tick, 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) @@ -22,6 +23,22 @@ def test_clip_slot_duplicate(client): client.send_message("/live/clip_slot/delete_clip", [0, 0]) client.send_message("/live/clip_slot/delete_clip", [0, 2]) +def test_clip_slot_fire_stop(client): + client.send_message("/live/clip_slot/create_clip", [0, 0, 4.0]) + # need delays since fire/stop doesn't trigger immediately + client.send_message("/live/clip_slot/fire", (0, 0)) + time.sleep(2) + assert client.query("/live/clip/get/is_playing", (0, 0)) == (0, 0, True) + client.send_message("/live/clip_slot/stop", (0, 0)) + time.sleep(2) + assert client.query("/live/clip/get/is_playing", (0, 0)) == (0, 0, False) + client.send_message("/live/clip_slot/set_fire_button_state", (0, 0, 1)) + time.sleep(2) + 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. + client.send_message("/live/clip_slot/delete_clip", [0, 0]) + + 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) @@ -29,4 +46,50 @@ def test_clip_slot_property_listen(client): 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 + client.send_message("/live/clip_slot/stop_listen/has_clip", (0, 0)) + +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_clip", [0, 0, 4.0]) + + # 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)) + + client.send_message("/live/clip_slot/delete_clip", [0, 0]) From e6b318604d35308fe147e2579c64eb5f628a4f20 Mon Sep 17 00:00:00 2001 From: PhotonicVelocity Date: Thu, 29 Jan 2026 14:57:31 -0800 Subject: [PATCH 4/6] spelling --- abletonosc/clip_slot.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/abletonosc/clip_slot.py b/abletonosc/clip_slot.py index 8b452f6..1c955d2 100644 --- a/abletonosc/clip_slot.py +++ b/abletonosc/clip_slot.py @@ -33,7 +33,7 @@ def clip_slot_callback(params: Tuple[Any]): # "duplicate_clip_slot", # Uses custom handler "set_fire_button_state", ] - properties = [ # (name, writeable, observable) + properties = [ # (name, writable, observable) ("color", 0, 1), # Only for Group Track slots ("color_index", 0, 1), # Only for Group Track slots ("controls_other_clips", 0, 1), # Only for Group Track slots @@ -51,10 +51,10 @@ def clip_slot_callback(params: Tuple[Any]): self.osc_server.add_handler("/live/clip_slot/%s" % method, create_clip_slot_callback(self._call_method, method)) - for prop, writeable, observable in properties: + for prop, writable, observable in properties: self.osc_server.add_handler("/live/clip_slot/get/%s" % prop, create_clip_slot_callback(self._get_property, prop)) - if writeable: + if writable: self.osc_server.add_handler("/live/clip_slot/set/%s" % prop, create_clip_slot_callback(self._set_property, prop)) if observable: From 7484d45a047e8120a799818d4cbcd6fb4abbc924 Mon Sep 17 00:00:00 2001 From: PhotonicVelocity Date: Wed, 4 Feb 2026 14:48:18 -0800 Subject: [PATCH 5/6] dictionary based methods and properties --- README.md | 14 +-- abletonosc/clip_slot.py | 125 +++++++++++++++++--------- abletonosc/handler.py | 12 ++- tests/__init__.py | 36 ++++++++ tests/test_clip_slot.py | 188 ++++++++++++++++++++++++---------------- 5 files changed, 252 insertions(+), 123 deletions(-) diff --git a/README.md b/README.md index cf983b9..144b850 100644 --- a/README.md +++ b/README.md @@ -333,10 +333,11 @@ A Clip Slot represents a container for a clip. It is used to create and delete c | -------------------------------------------------- | ---------------------------------------------------------------- | ----------------------------------------------- | ---------------------------------------------------------------------------------------------------- | | /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_clip | track_index, clip_index, length | | Create a 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_clip_to | track_index, clip_index, target_track_index, target_clip_index | | Duplicate the clip to an empty target 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 | @@ -350,6 +351,7 @@ A Clip Slot represents a container for a clip. It is used to create and delete c | /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 | @@ -362,7 +364,8 @@ A Clip Slot represents a container for a clip. It is used to create and delete c | /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/set/has_stop_button | track_index, clip_index, has_stop_button | | Add or remove stop button (1=on, 0=off) | +| /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) | @@ -582,4 +585,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_slot.py b/abletonosc/clip_slot.py index 1c955d2..8adfd6f 100644 --- a/abletonosc/clip_slot.py +++ b/abletonosc/clip_slot.py @@ -7,16 +7,16 @@ 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) if rv is not None: @@ -24,49 +24,92 @@ def clip_slot_callback(params: Tuple[Any]): return clip_slot_callback - methods = [ - "fire", - "stop", - "create_clip", - "create_audio_clip", - "delete_clip", - # "duplicate_clip_slot", # Uses custom handler - "set_fire_button_state", - ] - properties = [ # (name, writable, observable) - ("color", 0, 1), # Only for Group Track slots - ("color_index", 0, 1), # Only for Group Track slots - ("controls_other_clips", 0, 1), # Only for Group Track slots - ("has_clip", 0, 1), - ("has_stop_button", 1, 1), - ("is_group_slot", 0, 0), - ("is_playing", 0, 0), - ("is_recording", 0, 0), - ("is_triggered", 0, 1), - ("playing_status", 0, 1), # Only for Group Track slots - ("will_record_on_start", 0, 0), - ] + 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, writable, observable in properties: - self.osc_server.add_handler("/live/clip_slot/get/%s" % prop, - create_clip_slot_callback(self._get_property, prop)) - if writable: - self.osc_server.add_handler("/live/clip_slot/set/%s" % prop, - create_clip_slot_callback(self._set_property, prop)) - if 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)) + # 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/tests/__init__.py b/tests/__init__.py index 5cec909..3164df9 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,44 @@ def client() -> AbletonOSCClient: yield client client.stop() +@pytest.fixture(scope="module") +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 + 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_slot.py b/tests/test_clip_slot.py index 30f32e5..bd2695e 100644 --- a/tests/test_clip_slot.py +++ b/tests/test_clip_slot.py @@ -1,95 +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) - client.send_message("/live/clip_slot/delete_clip", [0, 0]) - client.send_message("/live/clip_slot/delete_clip", [0, 2]) + 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/clip_slot/create_clip", [0, 0, 4.0]) - # need delays since fire/stop doesn't trigger immediately - client.send_message("/live/clip_slot/fire", (0, 0)) - time.sleep(2) - assert client.query("/live/clip/get/is_playing", (0, 0)) == (0, 0, True) - client.send_message("/live/clip_slot/stop", (0, 0)) - time.sleep(2) - assert client.query("/live/clip/get/is_playing", (0, 0)) == (0, 0, False) - client.send_message("/live/clip_slot/set_fire_button_state", (0, 0, 1)) - time.sleep(2) - 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. - client.send_message("/live/clip_slot/delete_clip", [0, 0]) + 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() 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, 0)) + 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_clip", [0, 0, 4.0]) + client.send_message("/live/clip_slot/create/midi_clip", [0, 0, 4.0]) - # 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) + 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) + # 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)) + # 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)) - client.send_message("/live/clip_slot/delete_clip", [0, 0]) + finally: + client.send_message("/live/clip_slot/delete/clip", [0, 0]) + wait_one_tick() From 8a9f7d10e2528a31d55037af86c506ebe5beefe4 Mon Sep 17 00:00:00 2001 From: PhotonicVelocity Date: Wed, 4 Feb 2026 15:46:47 -0800 Subject: [PATCH 6/6] fix clip_slot logging format --- abletonosc/clip_slot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/abletonosc/clip_slot.py b/abletonosc/clip_slot.py index 8adfd6f..54c7e8f 100644 --- a/abletonosc/clip_slot.py +++ b/abletonosc/clip_slot.py @@ -18,7 +18,7 @@ def clip_slot_callback(params: Tuple[Any]): else: 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)