diff --git a/README.md b/README.md
index 083bb8d..144b850 100644
--- a/README.md
+++ b/README.md
@@ -329,15 +329,43 @@ A Clip Slot represents a container for a clip. It is used to create and delete c
Documentation: Clip Slot API
-| Address | Query params | Response params | Description |
-|:------------------------------------|:---------------------------------------------------------------|:-----------------------------------------|:------------------------------------------------|
-| /live/clip_slot/fire | track_index, clip_index | | Fire play/pause of the specified clip slot |
-| /live/clip_slot/create_clip | track_index, clip_index, length | | Create a clip in the slot |
-| /live/clip_slot/delete_clip | track_index, clip_index | | Delete the clip in the slot |
-| /live/clip_slot/get/has_clip | track_index, clip_index | track_index, clip_index, has_clip | Query whether the slot has a clip |
-| /live/clip_slot/get/has_stop_button | track_index, clip_index | track_index, clip_index, has_stop_button | Query whether the slot has a stop button |
-| /live/clip_slot/set/has_stop_button | track_index, clip_index, has_stop_button | | Add or remove stop button (1=on, 0=off) |
-| /live/clip_slot/duplicate_clip_to | track_index, clip_index, target_track_index, target_clip_index | | Duplicate the clip to an empty target clip slot |
+| Address | Query params | Response params | Description |
+| -------------------------------------------------- | ---------------------------------------------------------------- | ----------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
+| /live/clip_slot/fire | track_index, clip_index | | Fire play/pause of the specified clip slot |
+| /live/clip_slot/stop | track_index, clip_index | | Stops playling/recording the specified clip slot |
+| /live/clip_slot/create/midi_clip | track_index, clip_index, length | | Create a MIDI clip in the slot |
+| /live/clip_slot/create/audio_clip | track_index, clip_index, file_path | | Create an audio clip from file in the slot |
+| /live/clip_slot/delete/clip | track_index, clip_index | | Delete the clip in the slot |
+| /live/clip_slot/duplicate_to | track_index, clip_index, target_track_index, target_clip_index | | Duplicate the clip to a target clip slot |
+| /live/clip_slot/set/fire_button | track_index, clip_index, fire_button_state | | Set fire button state directly (1=on, 0=off) |
+| /live/clip_slot/get/color | track_index, clip_index | track_index, clip_index, color | Get clip slot color; Group Track slots only |
+| /live/clip_slot/start_listen/color | track_index, clip_index | track_index, clip_index, color | Listen for slot color changes; Group Track slots only |
+| /live/clip_slot/stop_listen/color | track_index, clip_index | | Stop listening for slot color changes; Group Track slots only |
+| /live/clip_slot/get/color_index | track_index, clip_index | track_index, clip_index, color_index | Get clip slot color index (0-69); Group Track slots only |
+| /live/clip_slot/start_listen/color_index | track_index, clip_index | track_index, clip_index, color_index | Listen for slot color index changes; Group Track slots only |
+| /live/clip_slot/stop_listen/color_index | track_index, clip_index | | Stop listening for slot color index changes; Group Track slots only |
+| /live/clip_slot/get/controls_other_clips | track_index, clip_index | track_index, clip_index, controls_other_clips | Get whether slot controls other clips; Group Track slots only |
+| /live/clip_slot/start_listen/controls_other_clips | track_index, clip_index | track_index, clip_index, controls_other_clips | Listen for controls_other_clips changes; Group Track slots only |
+| /live/clip_slot/stop_listen/controls_other_clips | track_index, clip_index | | Stop listening for controls_other_clips changes; Group Track slots only |
+| /live/clip_slot/get/has_clip | track_index, clip_index | track_index, clip_index, has_clip | Query whether the slot has a clip |
+| /live/clip_slot/start_listen/has_clip | track_index, clip_index | track_index, clip_index, has_clip | Listen for has_clip changes |
+| /live/clip_slot/stop_listen/has_clip | track_index, clip_index | | Stop listening for has_clip changes |
+| /live/clip_slot/get/has_stop_button | track_index, clip_index | track_index, clip_index, has_stop_button | Query whether the slot has a stop button |
+| /live/clip_slot/set/has_stop_button | track_index, clip_index, has_stop_button | | Add or remove stop button (1=on, 0=off) |
+| /live/clip_slot/start_listen/has_stop_button | track_index, clip_index | track_index, clip_index, has_stop_button | Listen for has_stop_button changes |
+| /live/clip_slot/stop_listen/has_stop_button | track_index, clip_index | | Stop listening for has_stop_button changes |
+| /live/clip_slot/get/is_group_slot | track_index, clip_index | track_index, clip_index, is_group_slot | Query whether the slot is a group slot |
+| /live/clip_slot/get/is_playing | track_index, clip_index | track_index, clip_index, is_playing | Query whether the slot is playing (playing_status != 0) |
+| /live/clip_slot/get/is_recording | track_index, clip_index | track_index, clip_index, is_recording | Query whether the slot is recording (playing_status == 2) |
+| /live/clip_slot/get/is_triggered | track_index, clip_index | track_index, clip_index, is_triggered | Query whether the slot is triggered |
+| /live/clip_slot/start_listen/is_triggered | track_index, clip_index | track_index, clip_index, is_triggered | Listen for is_triggered changes |
+| /live/clip_slot/stop_listen/is_triggered | track_index, clip_index | | Stop listening for is_triggered changes |
+| /live/clip_slot/get/playing_status | track_index, clip_index | track_index, clip_index, playing_status | Query slot playing_status: At least one clip in Group Track slot is (1=playing, 2=recording) |
+| /live/clip_slot/start_listen/playing_status | track_index, clip_index | track_index, clip_index, playing_status | Listen for playing_status changes |
+| /live/clip_slot/stop_listen/playing_status | track_index, clip_index | | Stop listening for playing_status changes |
+| /live/clip_slot/get/will_record_on_start | track_index, clip_index | track_index, clip_index, will_record_on_start | Query whether slot will record on start |
+| /live/clip_slot/create_clip | track_index, clip_index, length | | Create a MIDI clip in the slot (kept for backwards-compatibility) |
+| /live/clip_slot/delete_clip | track_index, clip_index | | Delete the clip in the slot (kept for backwards-compatibility) |
@@ -557,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 bfda31b..54c7e8f 100644
--- a/abletonosc/clip_slot.py
+++ b/abletonosc/clip_slot.py
@@ -7,61 +7,109 @@ def __init__(self, manager):
self.class_identifier = "clip_slot"
def init_api(self):
- def create_clip_slot_callback(func, *args, pass_clip_index=False):
+ def create_clip_slot_callback(func, *args, pass_clip_index=False, **kwargs):
def clip_slot_callback(params: Tuple[Any]):
track_index, clip_index = int(params[0]), int(params[1])
track = self.song.tracks[track_index]
clip_slot = track.clip_slots[clip_index]
if pass_clip_index:
- rv = func(clip_slot, *args, tuple(params[0:]))
+ rv = func(clip_slot, *args, tuple(params[0:]), **kwargs)
else:
- rv = func(clip_slot, *args, tuple(params[2:]))
+ rv = func(clip_slot, *args, tuple(params[2:]), **kwargs)
- self.logger.info(track_index, clip_index, rv)
+ self.logger.info("clip_slot %s,%s -> %s", track_index, clip_index, rv)
if rv is not None:
return (track_index, clip_index, *rv)
return clip_slot_callback
- methods = [
- "fire",
- "stop",
- "create_clip",
- "delete_clip"
- ]
- properties_r = [
- "has_clip",
- "controls_other_clips",
- "is_group_slot",
- "is_playing",
- "is_triggered",
- "playing_status",
- "will_record_on_start",
- ]
- properties_rw = [
- "has_stop_button"
- ]
+ methods = {
+ "fire": {"alias": 0, "caller": 1},
+ "stop": {"alias": 0, "caller": 1},
+ "create_clip": {"alias": 0, "caller": 1}, # Back-compat
+ "create/midi_clip": {"alias": 1, "caller": "create_clip"},
+ "create/audio_clip": {"alias": 1, "caller": "create_audio_clip"},
+ "delete_clip": {"alias": 0, "caller": 1}, # Back-compat
+ "delete/clip": {"alias": 1, "caller": "delete_clip"},
+ "duplicate_to": {"alias": 0, "caller": "clip_slot_duplicate_to"},
+ "set/fire_button": {"alias": 1, "caller": "set_fire_button_state"},
+ }
- for method in methods:
- self.osc_server.add_handler("/live/clip_slot/%s" % method,
- create_clip_slot_callback(self._call_method, method))
+ properties = {
+ # TODO: returns objects, needs serialization
+ # "canonical_parent": {"get": 0, "set": 0, "listen": 0},
+ # "clip": {"get": 0, "set": 0, "listen": 0},
- for prop in properties_r + properties_rw:
- self.osc_server.add_handler("/live/clip_slot/get/%s" % prop,
- create_clip_slot_callback(self._get_property, prop))
- self.osc_server.add_handler("/live/clip_slot/start_listen/%s" % prop,
- create_clip_slot_callback(self._start_listen, prop, pass_clip_index=True))
- self.osc_server.add_handler("/live/clip_slot/stop_listen/%s" % prop,
- create_clip_slot_callback(self._stop_listen, prop, pass_clip_index=True))
- for prop in properties_rw:
- self.osc_server.add_handler("/live/clip_slot/set/%s" % prop,
- create_clip_slot_callback(self._set_property, prop))
+ # All clip slots
+ "has_clip": {"get": 1, "set": 0, "listen": 1},
+ "has_stop_button": {"get": 1, "set": 1, "listen": 1},
+ "is_group_slot": {"get": 1, "set": 0, "listen": 0},
+ "is_playing": {"get": 1, "set": 0, "listen": 0},
+ "is_recording": {"get": 1, "set": 0, "listen": 0},
+ "is_triggered": {"get": 1, "set": 0, "listen": 1},
+ "will_record_on_start": {"get": 1, "set": 0, "listen": 0},
+
+ # Group Track slots only
+ "color": {"get": 1, "set": 0, "listen": 1},
+ "color_index": {"get": 1, "set": 0, "listen": 1},
+ "controls_other_clips": {"get": 1, "set": 0, "listen": 1},
+ "playing_status": {"get": 1, "set": 0, "listen": 1},
- def duplicate_clip_slot(clip_slot, args):
+
+ }
+
+ def clip_slot_duplicate_to(clip_slot, args):
target_track_index, target_clip_index = tuple(args)
track = self.song.tracks[target_track_index]
target_clip_slot = track.clip_slots[target_clip_index]
clip_slot.duplicate_clip_to(target_clip_slot)
- self.osc_server.add_handler("/live/clip_slot/duplicate_clip_to", create_clip_slot_callback(duplicate_clip_slot))
+ # Add Handlers
+ local_funcs = locals()
+ for method, spec in methods.items():
+ alias = spec.get("alias")
+ caller = spec.get("caller")
+ if not caller:
+ continue
+ if not alias and isinstance(caller, str):
+ caller = local_funcs[caller]
+ self.osc_server.add_handler("/live/clip_slot/%s" % method,
+ create_clip_slot_callback(caller))
+ else:
+ if not alias:
+ caller = method
+ self.osc_server.add_handler("/live/clip_slot/%s" % method,
+ create_clip_slot_callback(self._call_method, caller))
+
+ for prop, spec in properties.items():
+ getter_func = spec.get("get")
+ if isinstance(getter_func, str):
+ getter = local_funcs[getter_func]
+ self.osc_server.add_handler("/live/clip_slot/get/%s" % prop,
+ create_clip_slot_callback(getter))
+ elif getter_func:
+ self.osc_server.add_handler("/live/clip_slot/get/%s" % prop,
+ create_clip_slot_callback(self._get_property, prop))
+
+ setter_func = spec.get("set")
+ if isinstance(setter_func, str):
+ setter = local_funcs[setter_func]
+ self.osc_server.add_handler("/live/clip_slot/set/%s" % prop,
+ create_clip_slot_callback(setter))
+ elif setter_func:
+ self.osc_server.add_handler("/live/clip_slot/set/%s" % prop,
+ create_clip_slot_callback(self._set_property, prop))
+
+ observable = spec.get("listen")
+ if isinstance(observable, str):
+ getter = "bang" if observable == "bang" else local_funcs[observable]
+ self.osc_server.add_handler("/live/clip_slot/start_listen/%s" % prop,
+ create_clip_slot_callback(self._start_listen, prop, pass_clip_index=True, getter=getter))
+ self.osc_server.add_handler("/live/clip_slot/stop_listen/%s" % prop,
+ create_clip_slot_callback(self._stop_listen, prop, pass_clip_index=True))
+ elif observable:
+ self.osc_server.add_handler("/live/clip_slot/start_listen/%s" % prop,
+ create_clip_slot_callback(self._start_listen, prop, pass_clip_index=True))
+ self.osc_server.add_handler("/live/clip_slot/stop_listen/%s" % prop,
+ create_clip_slot_callback(self._stop_listen, prop, pass_clip_index=True))
diff --git a/abletonosc/handler.py b/abletonosc/handler.py
index 55d4913..2687871 100644
--- a/abletonosc/handler.py
+++ b/abletonosc/handler.py
@@ -61,8 +61,13 @@ def _start_listen(self, target, prop, params: Optional[Tuple] = (), getter = Non
def property_changed_callback():
if getter is None:
value = getattr(target, prop)
+ elif getter == "bang":
+ value = 1
else:
- value = getter(params)
+ try:
+ value = getter(target, params)
+ except TypeError:
+ value = getter(params)
if type(value) is not tuple:
value = (value,)
self.logger.info("Property %s changed of %s %s: %s" % (prop, self.class_identifier, str(params), value))
@@ -80,9 +85,10 @@ def property_changed_callback():
self.listener_functions[listener_key] = property_changed_callback
self.listener_objects[listener_key] = target
#--------------------------------------------------------------------------------
- # Immediately send the current value
+ # Immediately send the current value (skip for bang-only listeners)
#--------------------------------------------------------------------------------
- property_changed_callback()
+ if getter != "bang":
+ property_changed_callback()
def _stop_listen(self, target, prop, params: Optional[Tuple[Any]] = ()) -> None:
listener_key = (prop, tuple(params))
diff --git a/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 4d518cf..bd2695e 100644
--- a/tests/test_clip_slot.py
+++ b/tests/test_clip_slot.py
@@ -1,32 +1,137 @@
-from . import client, wait_one_tick, TICK_DURATION
+from . import client, wait_one_tick, silent_audio_file, remove_audio_file, TICK_DURATION
+import time
-def test_clip_slot_has_clip(client):
- assert client.query("/live/clip_slot/get/has_clip", (0, 0)) == (0, 0, False)
- client.send_message("/live/clip_slot/create_clip", (0, 0, 4.0))
- assert client.query("/live/clip_slot/get/has_clip", (0, 0)) == (0, 0, True)
- client.send_message("/live/clip_slot/delete_clip", (0, 0))
+def test_clip_slot_create_clips(client, silent_audio_file):
+ try:
+ # MIDI Clip
+ assert client.query("/live/clip_slot/get/has_clip", (0, 0)) == (0, 0, False)
+ client.send_message("/live/clip_slot/create/midi_clip", (0, 0, 4.0))
+ wait_one_tick()
+ assert client.query("/live/clip_slot/get/has_clip", (0, 0)) == (0, 0, True)
+
+ # Audio Clip
+ assert client.query("/live/clip_slot/get/has_clip", (2, 0)) == (2, 0, False)
+ client.send_message("/live/clip_slot/create/audio_clip", (2, 0, str(silent_audio_file)))
+ wait_one_tick()
+ assert client.query("/live/clip_slot/get/has_clip", (2, 0)) == (2, 0, True)
+
+ finally:
+ client.send_message("/live/clip_slot/delete/clip", (0, 0))
+ client.send_message("/live/clip_slot/delete/clip", (2, 0))
+ remove_audio_file(silent_audio_file)
+ wait_one_tick()
+
+def test_clip_slot_create_clip_back_compat(client):
+ try:
+ # MIDI Clip
+ assert client.query("/live/clip_slot/get/has_clip", (0, 0)) == (0, 0, False)
+ client.send_message("/live/clip_slot/create_clip", (0, 0, 4.0))
+ wait_one_tick()
+ assert client.query("/live/clip_slot/get/has_clip", (0, 0)) == (0, 0, True)
+
+ finally:
+ client.send_message("/live/clip_slot/delete_clip", (0, 0))
+ wait_one_tick()
def test_clip_slot_duplicate(client):
- client.send_message("/live/clip_slot/create_clip", [0, 0, 4.0])
- client.send_message("/live/clip/get/notes", (0, 0))
- assert client.await_message("/live/clip/get/notes") == (0, 0)
+ try:
+ client.send_message("/live/clip_slot/create/midi_clip", [0, 0, 4.0])
+ client.send_message("/live/clip/get/notes", (0, 0))
+ assert client.await_message("/live/clip/get/notes") == (0, 0)
- client.send_message("/live/clip/add/notes", (0, 0,
- 60, 0.0, 0.25, 64, False))
+ client.send_message("/live/clip/add/notes", (0, 0,
+ 60, 0.0, 0.25, 64, False))
- client.send_message("/live/clip_slot/duplicate_clip_to", (0, 0, 0, 2))
- client.send_message("/live/clip/get/notes", (0, 2))
- assert client.await_message("/live/clip/get/notes") == (0, 2,
- 60, 0.0, 0.25, 64, False)
+ client.send_message("/live/clip_slot/duplicate_to", (0, 0, 0, 2))
+ client.send_message("/live/clip/get/notes", (0, 2))
+ assert client.await_message("/live/clip/get/notes") == (0, 2,
+ 60, 0.0, 0.25, 64, False)
+
+ finally:
+ client.send_message("/live/clip_slot/delete/clip", [0, 0])
+ client.send_message("/live/clip_slot/delete/clip", [0, 2])
+ wait_one_tick()
+
+def test_clip_slot_fire_stop(client):
+ client.send_message("/live/song/stop_playing")
+ client.send_message("/live/song/set/clip_trigger_quantization", (0))
+ try:
+ client.send_message("/live/clip_slot/create/midi_clip", [0, 0, 4.0])
+ # need delays since fire/stop doesn't trigger immediately
+ client.send_message("/live/clip_slot/fire", (0, 0))
+ wait_one_tick()
+ assert client.query("/live/clip/get/is_playing", (0, 0)) == (0, 0, True)
+ client.send_message("/live/clip_slot/stop", (0, 0))
+ wait_one_tick()
+ assert client.query("/live/clip/get/is_playing", (0, 0)) == (0, 0, False)
+ client.send_message("/live/clip_slot/set/fire_button", (0, 0, 1))
+ wait_one_tick()
+ assert client.query("/live/clip/get/is_playing", (0, 0)) == (0, 0, True)
+ client.send_message("/live/clip_slot/stop", (0, 0)) # set_fire_button_state to 0 does nothing.
+ finally:
+ client.send_message("/live/song/stop_playing")
+ client.send_message("/live/clip_slot/delete/clip", [0, 0])
+ wait_one_tick()
- client.send_message("/live/clip_slot/delete_clip", [0, 0])
- client.send_message("/live/clip_slot/delete_clip", [0, 2])
def test_clip_slot_property_listen(client):
- client.send_message("/live/clip_slot/start_listen/has_clip", (0, 0))
- assert client.await_message("/live/clip_slot/get/has_clip", TICK_DURATION * 2) == (0, 0, False)
- client.send_message("/live/clip_slot/create_clip", [0, 0, 4.0])
- assert client.await_message("/live/clip_slot/get/has_clip", TICK_DURATION * 2) == (0, 0, True)
- client.send_message("/live/clip_slot/delete_clip", [0, 0])
- assert client.await_message("/live/clip_slot/get/has_clip", TICK_DURATION * 2) == (0, 0, False)
- client.send_message("/live/clip_slot/stop_listen/has_clip", (0,))
\ No newline at end of file
+ try:
+ client.send_message("/live/clip_slot/start_listen/has_clip", (0, 0))
+ assert client.await_message("/live/clip_slot/get/has_clip", TICK_DURATION * 2) == (0, 0, False)
+ client.send_message("/live/clip_slot/create/midi_clip", [0, 0, 4.0])
+ assert client.await_message("/live/clip_slot/get/has_clip", TICK_DURATION * 2) == (0, 0, True)
+ client.send_message("/live/clip_slot/delete/clip", [0, 0])
+ assert client.await_message("/live/clip_slot/get/has_clip", TICK_DURATION * 2) == (0, 0, False)
+ client.send_message("/live/clip_slot/stop_listen/has_clip", (0, 0))
+ finally:
+ client.send_message("/live/clip_slot/delete/clip", [0, 0])
+ wait_one_tick()
+
+def _assert_clip_slot_get(client, prop, track_index=0, clip_index=0):
+ rv = client.query(f"/live/clip_slot/get/{prop}", (track_index, clip_index))
+ assert rv[0] == track_index and rv[1] == clip_index
+
+def test_clip_slot_endpoints(client):
+ client.send_message("/live/clip_slot/create/midi_clip", [0, 0, 4.0])
+
+ try:
+ # get read_only properties
+ for prop in [
+ "color",
+ "color_index",
+ "controls_other_clips",
+ "has_clip",
+ "has_stop_button",
+ "is_group_slot",
+ "is_playing",
+ "is_recording",
+ "is_triggered",
+ "playing_status",
+ "will_record_on_start",
+ ]:
+ _assert_clip_slot_get(client, prop, 0, 0)
+
+ # set has_stop_button (rw property)
+ client.send_message("/live/clip_slot/set/has_stop_button", (0, 0, 1))
+ assert client.query("/live/clip_slot/get/has_stop_button", (0, 0)) == (0, 0, True)
+ client.send_message("/live/clip_slot/set/has_stop_button", (0, 0, 0))
+ assert client.query("/live/clip_slot/get/has_stop_button", (0, 0)) == (0, 0, False)
+
+ # check listeners are created
+ for prop in [
+ "color",
+ "color_index",
+ "controls_other_clips",
+ "has_clip",
+ "has_stop_button",
+ "is_triggered",
+ "playing_status",
+ ]:
+ client.send_message(f"/live/clip_slot/start_listen/{prop}", (0, 0))
+ rv = client.await_message(f"/live/clip_slot/get/{prop}", TICK_DURATION * 2)
+ assert rv[0] == 0 and rv[1] == 0
+ client.send_message(f"/live/clip_slot/stop_listen/{prop}", (0, 0))
+
+ finally:
+ client.send_message("/live/clip_slot/delete/clip", [0, 0])
+ wait_one_tick()