From fe2b9083358c265c0d7f328405dc7c7130e53127 Mon Sep 17 00:00:00 2001 From: miceo Date: Thu, 20 Nov 2025 11:10:58 +0100 Subject: [PATCH 01/15] Add handler for creating midi and audio clips on tracks --- README.md | 8 +++++--- abletonosc/track.py | 4 +++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 083bb8d..e76c89c 100644 --- a/README.md +++ b/README.md @@ -231,9 +231,11 @@ To query the properties of multiple tracks, see [Song: Properties of cue points, ### Track methods -| Address | Query params | Response params | Description | -|:---------------------------|:-------------|:----------------|:------------------------| -| /live/track/stop_all_clips | track_id | | Stop all clips on track | +| Address | Query params | Response params | Description | +|:------------------------------|:----------------------------------|:----------------|:-------------------------| +| /live/track/stop_all_clips | track_id | | Stop all clips on track | +| /live/track/create_audio_clip | track_id, file_path, position | | Adds audio clip to track | +| /live/track/create_midi_clip | track_id, position, length | | Adds midi clip to track | ### Track properties diff --git a/abletonosc/track.py b/abletonosc/track.py index 5e21353..3c95c7c 100644 --- a/abletonosc/track.py +++ b/abletonosc/track.py @@ -31,7 +31,9 @@ def track_callback(params: Tuple[Any]): methods = [ "delete_device", - "stop_all_clips" + "stop_all_clips", + "create_audio_clip", + "create_midi_clip", ] properties_r = [ "can_be_armed", From 477a5b345c4db7d97b320539b2dbbc50528d17c8 Mon Sep 17 00:00:00 2001 From: miceo Date: Thu, 20 Nov 2025 12:23:15 +0100 Subject: [PATCH 02/15] Clarified clip descriptions in README.md --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index e76c89c..baeea98 100644 --- a/README.md +++ b/README.md @@ -231,11 +231,11 @@ To query the properties of multiple tracks, see [Song: Properties of cue points, ### Track methods -| Address | Query params | Response params | Description | -|:------------------------------|:----------------------------------|:----------------|:-------------------------| -| /live/track/stop_all_clips | track_id | | Stop all clips on track | -| /live/track/create_audio_clip | track_id, file_path, position | | Adds audio clip to track | -| /live/track/create_midi_clip | track_id, position, length | | Adds midi clip to track | +| Address | Query params | Response params | Description | +|:------------------------------|:----------------------------------|:----------------|:----------------------------------------------------------------------------| +| /live/track/stop_all_clips | track_id | | Stop all clips on track | +| /live/track/create_audio_clip | track_id, file_path, position | | Add sample at absolute file path as audio clip to track in arrangement view | +| /live/track/create_midi_clip | track_id, position, length | | Add midi clip to track in arrangement view | ### Track properties From 1fbbf3cf914968185bbf1271c8a7eadf13f97e0d Mon Sep 17 00:00:00 2001 From: Lucas Draney Date: Thu, 15 Jan 2026 11:23:20 -0700 Subject: [PATCH 03/15] fix: duplicate_clip_to_arrangement passes clip instead of clip_slot The Ableton API expects a Clip object, not a ClipSlot object. Changed from clip_slot to clip_slot.clip with null check. Co-Authored-By: Claude Opus 4.5 --- abletonosc/track.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/abletonosc/track.py b/abletonosc/track.py index 5e21353..9222f84 100644 --- a/abletonosc/track.py +++ b/abletonosc/track.py @@ -136,6 +136,19 @@ def track_get_arrangement_clip_start_times(track, _): self.osc_server.add_handler("/live/track/get/arrangement_clips/length", create_track_callback(track_get_arrangement_clip_lengths)) self.osc_server.add_handler("/live/track/get/arrangement_clips/start_time", create_track_callback(track_get_arrangement_clip_start_times)) + def track_duplicate_clip_to_arrangement(track, params): + """ + Duplicate a session clip to arrangement view at a specific time. + params: (clip_slot_index, time_in_beats) + """ + clip_slot_index = int(params[0]) + time = float(params[1]) + clip_slot = track.clip_slots[clip_slot_index] + if clip_slot.clip: + track.duplicate_clip_to_arrangement(clip_slot.clip, time) + + self.osc_server.add_handler("/live/track/duplicate_clip_to_arrangement", create_track_callback(track_duplicate_clip_to_arrangement)) + def track_get_num_devices(track, _): return len(track.devices), From 4c4d60e837f598cff903cf3cee1a85cd50b5d0a9 Mon Sep 17 00:00:00 2001 From: PhotonicVelocity Date: Wed, 28 Jan 2026 13:04:41 -0800 Subject: [PATCH 04/15] 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 05/15] 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 06/15] 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 07/15] 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 08/15] 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 09/15] 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) From a57557fe85919e8d713fd9e052a5724abcdec24b Mon Sep 17 00:00:00 2001 From: PhotonicVelocity Date: Mon, 9 Feb 2026 11:20:26 -0800 Subject: [PATCH 10/15] updated to dictionary-based registry --- abletonosc/track.py | 317 +++++++++++++++++++++++++------------------- tests/test_track.py | 61 +++++---- 2 files changed, 218 insertions(+), 160 deletions(-) diff --git a/abletonosc/track.py b/abletonosc/track.py index 7249679..25d9ac4 100644 --- a/abletonosc/track.py +++ b/abletonosc/track.py @@ -29,154 +29,111 @@ def track_callback(params: Tuple[Any]): return track_callback - methods = [ - "delete_device", - "stop_all_clips", - "create_audio_clip", - "create_midi_clip", - ] - properties_r = [ - "can_be_armed", - "fired_slot_index", - "has_audio_input", - "has_audio_output", - "has_midi_input", - "has_midi_output", - "is_foldable", - "is_grouped", - "is_visible", - "output_meter_level", - "output_meter_left", - "output_meter_right", - "playing_slot_index", - ] - properties_rw = [ - "arm", - "color", - "color_index", - "current_monitoring_state", - "fold_state", - "mute", - "solo", - "name" - ] - - for method in methods: - self.osc_server.add_handler("/live/track/%s" % method, - create_track_callback(self._call_method, method)) - - for prop in properties_r + properties_rw: - self.osc_server.add_handler("/live/track/get/%s" % prop, - create_track_callback(self._get_property, prop)) - self.osc_server.add_handler("/live/track/start_listen/%s" % prop, - create_track_callback(self._start_listen, prop, include_track_id=True)) - self.osc_server.add_handler("/live/track/stop_listen/%s" % prop, - create_track_callback(self._stop_listen, prop, include_track_id=True)) - for prop in properties_rw: - self.osc_server.add_handler("/live/track/set/%s" % prop, - create_track_callback(self._set_property, prop)) - - #-------------------------------------------------------------------------------- - # Volume, panning and send are properties of the track's mixer_device so - # can't be formulated as normal callbacks that reference properties of track. - #-------------------------------------------------------------------------------- - mixer_properties_rw = ["volume", "panning"] - for prop in mixer_properties_rw: - self.osc_server.add_handler("/live/track/get/%s" % prop, - create_track_callback(self._get_mixer_property, prop)) - self.osc_server.add_handler("/live/track/set/%s" % prop, - create_track_callback(self._set_mixer_property, prop)) - self.osc_server.add_handler("/live/track/start_listen/%s" % prop, - create_track_callback(self._start_mixer_listen, prop, include_track_id=True)) - self.osc_server.add_handler("/live/track/stop_listen/%s" % prop, - create_track_callback(self._stop_mixer_listen, prop, include_track_id=True)) - - # Still need to fix these - # Might want to find a better approach that unifies volume and sends - def track_get_send(track, params: Tuple[Any] = ()): - send_id, = params - return send_id, track.mixer_device.sends[send_id].value - - def track_set_send(track, params: Tuple[Any] = ()): - send_id, value = params - track.mixer_device.sends[send_id].value = value - - self.osc_server.add_handler("/live/track/get/send", create_track_callback(track_get_send)) - self.osc_server.add_handler("/live/track/set/send", create_track_callback(track_set_send)) - + methods = { + "delete_device": {"alias": 0, "caller": 1}, + "delete_clip": {"alias": 0, "caller": "track_delete_clip"}, # Shortcut to clip slot delete + "stop_all_clips": {"alias": 0, "caller": 1}, + "duplicate_clip_to_arrangement": {"alias": 0, "caller": "track_duplicate_clip_to_arrangement"}, + } + properties = { + "can_be_armed": {"get": 1, "set": 0, "listen": 0}, # listener removed + "fired_slot_index": {"get": 1, "set": 0, "listen": 1}, + "has_audio_input": {"get": 1, "set": 0, "listen": 1}, + "has_audio_output": {"get": 1, "set": 0, "listen": 1}, + "has_midi_input": {"get": 1, "set": 0, "listen": 1}, + "has_midi_output": {"get": 1, "set": 0, "listen": 1}, + "is_foldable": {"get": 1, "set": 0, "listen": 0}, # listener removed + "is_grouped": {"get": 1, "set": 0, "listen": 0}, # listener removed + "is_visible": {"get": 1, "set": 0, "listen": 0}, # listener removed + "output_meter_level": {"get": 1, "set": 0, "listen": 1}, + "output_meter_left": {"get": 1, "set": 0, "listen": 1}, + "output_meter_right": {"get": 1, "set": 0, "listen": 1}, + "playing_slot_index": {"get": 1, "set": 0, "listen": 1}, + "arm": {"get": 1, "set": 1, "listen": 1}, + "color": {"get": 1, "set": 1, "listen": 1}, + "color_index": {"get": 1, "set": 1, "listen": 1}, + "current_monitoring_state": {"get": 1, "set": 1, "listen": 1}, + "fold_state": {"get": 1, "set": 1, "listen": 0}, # listener removed + "mute": {"get": 1, "set": 1, "listen": 1}, + "solo": {"get": 1, "set": 1, "listen": 1}, + "name": {"get": 1, "set": 1, "listen": 1}, + # Clip lists + "clips/name": {"get": "track_get_clip_names", "set": 0, "listen": 0}, + "clips/length": {"get": "track_get_clip_lengths", "set": 0, "listen": 0}, + "clips/color": {"get": "track_get_clip_colors", "set": 0, "listen": 0}, + "arrangement_clips/name": {"get": "track_get_arrangement_clip_names", "set": 0, "listen": 0}, + "arrangement_clips/length": {"get": "track_get_arrangement_clip_lengths", "set": 0, "listen": 0}, + "arrangement_clips/start_time": {"get": "track_get_arrangement_clip_start_times", "set": 0, "listen": 0}, + # Device lists + "num_devices": {"get": "track_get_num_devices", "set": 0, "listen": 0}, + "devices/name": {"get": "track_get_device_names", "set": 0, "listen": 0}, + "devices/type": {"get": "track_get_device_types", "set": 0, "listen": 0}, + "devices/class_name": {"get": "track_get_device_class_names", "set": 0, "listen": 0}, + "devices/can_have_chains": {"get": "track_get_device_can_have_chains", "set": 0, "listen": 0}, + # Routing + "available_output_routing_types": {"get": "track_get_available_output_routing_types", "set": 0, "listen": 0}, + "available_output_routing_channels": {"get": "track_get_available_output_routing_channels", "set": 0, "listen": 0}, + "output_routing_type": {"get": "track_get_output_routing_type", "set": "track_set_output_routing_type", "listen": 0}, + "output_routing_channel": {"get": "track_get_output_routing_channel", "set": "track_set_output_routing_channel", "listen": 0}, + "available_input_routing_types": {"get": "track_get_available_input_routing_types", "set": 0, "listen": 0}, + "available_input_routing_channels": {"get": "track_get_available_input_routing_channels", "set": 0, "listen": 0}, + "input_routing_type": {"get": "track_get_input_routing_type", "set": "track_set_input_routing_type", "listen": 0}, + "input_routing_channel": {"get": "track_get_input_routing_channel", "set": "track_set_input_routing_channel", "listen": 0}, + + } + + def track_duplicate_clip_to_arrangement(track, params): + """ + Duplicate a session clip to arrangement view at a specific time. + params: (clip_slot_index, time_in_beats) + """ + clip_slot_index = int(params[0]) + time = float(params[1]) + clip_slot = track.clip_slots[clip_slot_index] + if clip_slot.clip: + track.duplicate_clip_to_arrangement(clip_slot.clip, time) + + """ + Shortcut to clip slot delete + """ def track_delete_clip(track, params: Tuple[Any]): clip_index, = params track.clip_slots[clip_index].delete_clip() - self.osc_server.add_handler("/live/track/delete_clip", create_track_callback(track_delete_clip)) + #-------------------------------------------------------------------------------- + # Track.[Arrangement_]Clip: List Properties + #-------------------------------------------------------------------------------- def track_get_clip_names(track, _): return tuple(clip_slot.clip.name if clip_slot.clip else None for clip_slot in track.clip_slots) - def track_get_clip_lengths(track, _): return tuple(clip_slot.clip.length if clip_slot.clip else None for clip_slot in track.clip_slots) - def track_get_clip_colors(track, _): return tuple(clip_slot.clip.color if clip_slot.clip else None for clip_slot in track.clip_slots) - def track_get_arrangement_clip_names(track, _): return tuple(clip.name for clip in track.arrangement_clips) - def track_get_arrangement_clip_lengths(track, _): return tuple(clip.length for clip in track.arrangement_clips) - def track_get_arrangement_clip_start_times(track, _): return tuple(clip.start_time for clip in track.arrangement_clips) - - """ - Returns a list of clip properties, or Nil if clip is empty - """ - self.osc_server.add_handler("/live/track/get/clips/name", create_track_callback(track_get_clip_names)) - self.osc_server.add_handler("/live/track/get/clips/length", create_track_callback(track_get_clip_lengths)) - self.osc_server.add_handler("/live/track/get/clips/color", create_track_callback(track_get_clip_colors)) - self.osc_server.add_handler("/live/track/get/arrangement_clips/name", create_track_callback(track_get_arrangement_clip_names)) - self.osc_server.add_handler("/live/track/get/arrangement_clips/length", create_track_callback(track_get_arrangement_clip_lengths)) - self.osc_server.add_handler("/live/track/get/arrangement_clips/start_time", create_track_callback(track_get_arrangement_clip_start_times)) - - def track_duplicate_clip_to_arrangement(track, params): - """ - Duplicate a session clip to arrangement view at a specific time. - params: (clip_slot_index, time_in_beats) - """ - clip_slot_index = int(params[0]) - time = float(params[1]) - clip_slot = track.clip_slots[clip_slot_index] - if clip_slot.clip: - track.duplicate_clip_to_arrangement(clip_slot.clip, time) - - self.osc_server.add_handler("/live/track/duplicate_clip_to_arrangement", create_track_callback(track_duplicate_clip_to_arrangement)) - + + #-------------------------------------------------------------------------------- + # Track.Device: List Properties + # - name: the device's human-readable name + # - type: 0 = audio_effect, 1 = instrument, 2 = midi_effect + # - class_name: e.g. Operator, Reverb, AuPluginDevice, PluginDevice, InstrumentGroupDevice + #-------------------------------------------------------------------------------- def track_get_num_devices(track, _): return len(track.devices), - def track_get_device_names(track, _): return tuple(device.name for device in track.devices) - def track_get_device_types(track, _): return tuple(device.type for device in track.devices) - def track_get_device_class_names(track, _): return tuple(device.class_name for device in track.devices) - def track_get_device_can_have_chains(track, _): return tuple(device.can_have_chains for device in track.devices) - - """ - - name: the device's human-readable name - - type: 0 = audio_effect, 1 = instrument, 2 = midi_effect - - class_name: e.g. Operator, Reverb, AuPluginDevice, PluginDevice, InstrumentGroupDevice - """ - self.osc_server.add_handler("/live/track/get/num_devices", create_track_callback(track_get_num_devices)) - self.osc_server.add_handler("/live/track/get/devices/name", create_track_callback(track_get_device_names)) - self.osc_server.add_handler("/live/track/get/devices/type", create_track_callback(track_get_device_types)) - self.osc_server.add_handler("/live/track/get/devices/class_name", create_track_callback(track_get_device_class_names)) - self.osc_server.add_handler("/live/track/get/devices/can_have_chains", create_track_callback(track_get_device_can_have_chains)) - + #-------------------------------------------------------------------------------- # Track: Output routing. # An output route has a type (e.g. "Ext. Out") and a channel (e.g. "1/2"). @@ -206,13 +163,6 @@ def track_set_output_routing_channel(track, params): return self.logger.warning("Couldn't find output routing channel: %s" % channel_name) - self.osc_server.add_handler("/live/track/get/available_output_routing_types", create_track_callback(track_get_available_output_routing_types)) - self.osc_server.add_handler("/live/track/get/available_output_routing_channels", create_track_callback(track_get_available_output_routing_channels)) - self.osc_server.add_handler("/live/track/get/output_routing_type", create_track_callback(track_get_output_routing_type)) - self.osc_server.add_handler("/live/track/set/output_routing_type", create_track_callback(track_set_output_routing_type)) - self.osc_server.add_handler("/live/track/get/output_routing_channel", create_track_callback(track_get_output_routing_channel)) - self.osc_server.add_handler("/live/track/set/output_routing_channel", create_track_callback(track_set_output_routing_channel)) - #-------------------------------------------------------------------------------- # Track: Input routing. #-------------------------------------------------------------------------------- @@ -239,12 +189,111 @@ def track_set_input_routing_channel(track, params): return self.logger.warning("Couldn't find input routing channel: %s" % channel_name) - self.osc_server.add_handler("/live/track/get/available_input_routing_types", create_track_callback(track_get_available_input_routing_types)) - self.osc_server.add_handler("/live/track/get/available_input_routing_channels", create_track_callback(track_get_available_input_routing_channels)) - self.osc_server.add_handler("/live/track/get/input_routing_type", create_track_callback(track_get_input_routing_type)) - self.osc_server.add_handler("/live/track/set/input_routing_type", create_track_callback(track_set_input_routing_type)) - self.osc_server.add_handler("/live/track/get/input_routing_channel", create_track_callback(track_get_input_routing_channel)) - self.osc_server.add_handler("/live/track/set/input_routing_channel", create_track_callback(track_set_input_routing_channel)) + # 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/track/%s" % method, + create_track_callback(caller)) + else: + if not alias: + caller = method + self.osc_server.add_handler("/live/track/%s" % method, + create_track_callback(self._call_method, caller)) + + for prop, spec in properties.items(): + getter_func = spec.get("get") + if getter_func in (True, 1): + self.osc_server.add_handler("/live/track/get/%s" % prop, + create_track_callback(self._get_property, prop)) + elif isinstance(getter_func, str): + getter = local_funcs[getter_func] + self.osc_server.add_handler("/live/track/get/%s" % prop, + create_track_callback(getter)) + + setter_func = spec.get("set") + if setter_func in (True, 1): + self.osc_server.add_handler("/live/track/set/%s" % prop, + create_track_callback(self._set_property, prop)) + elif isinstance(setter_func, str): + setter = local_funcs[setter_func] + self.osc_server.add_handler("/live/track/set/%s" % prop, + create_track_callback(setter)) + + observable = spec.get("listen") + if observable: + self.osc_server.add_handler("/live/track/start_listen/%s" % prop, + create_track_callback(self._start_listen, prop, include_track_id=True)) + self.osc_server.add_handler("/live/track/stop_listen/%s" % prop, + create_track_callback(self._stop_listen, prop, include_track_id=True)) + + #-------------------------------------------------------------------------------- + # Mixer Properties + # Volume, panning and send are properties of the track's mixer_device so + # can't be formulated as normal callbacks that reference properties of track. + #-------------------------------------------------------------------------------- + + mixer_properties = { + "volume": {"get": 1, "set": 1, "listen": 1}, + "panning": {"get": 1, "set": 1, "listen": 1}, + "send": {"get": "track_get_send", "set": "track_set_send", "listen": 0}, + } + + # Still need to fix these + # Might want to find a better approach that unifies volume and sends + def track_get_send(track, params: Tuple[Any] = ()): + send_id, = params + return send_id, track.mixer_device.sends[send_id].value + + def track_set_send(track, params: Tuple[Any] = ()): + send_id, value = params + track.mixer_device.sends[send_id].value = value + + def track_get_crossfade_assign(track, _params: Tuple[Any] = ()): + return track.mixer_device.crossfade_assign, + + def track_set_crossfade_assign(track, params: Tuple[Any] = ()): + value, = params + track.mixer_device.crossfade_assign = value + + def track_get_panning_mode(track, _params: Tuple[Any] = ()): + return track.mixer_device.panning_mode, + + def track_set_panning_mode(track, params: Tuple[Any] = ()): + value, = params + track.mixer_device.panning_mode = value + + local_funcs = locals() + for prop, spec in mixer_properties.items(): + getter_func = spec.get("get") + if getter_func in (True, 1): + self.osc_server.add_handler("/live/track/get/%s" % prop, + create_track_callback(self._get_mixer_property, prop)) + elif isinstance(getter_func, str): + getter = local_funcs[getter_func] + self.osc_server.add_handler("/live/track/get/%s" % prop, + create_track_callback(getter)) + + setter_func = spec.get("set") + if setter_func in (True, 1): + self.osc_server.add_handler("/live/track/set/%s" % prop, + create_track_callback(self._set_mixer_property, prop)) + elif isinstance(setter_func, str): + setter = local_funcs[setter_func] + self.osc_server.add_handler("/live/track/set/%s" % prop, + create_track_callback(setter)) + + observable = spec.get("listen") + if observable: + self.osc_server.add_handler("/live/track/start_listen/%s" % prop, + create_track_callback(self._start_mixer_listen, prop, include_track_id=True)) + self.osc_server.add_handler("/live/track/stop_listen/%s" % prop, + create_track_callback(self._stop_mixer_listen, prop, include_track_id=True)) def _set_mixer_property(self, target, prop, params: Tuple) -> None: parameter_object = getattr(target.mixer_device, prop) diff --git a/tests/test_track.py b/tests/test_track.py index c1e90cd..1d532ce 100644 --- a/tests/test_track.py +++ b/tests/test_track.py @@ -81,29 +81,38 @@ def test_track_devices(client): def test_track_listen_playing_slot_index(client): # 1/16th quantize - client.send_message("/live/song/set/clip_trigger_quantization", (11,)) - for track_id, clip_id in itertools.product((0, 1), (0, 1)): - client.send_message("/live/clip_slot/create_clip", (track_id, clip_id, 4)) - - client.send_message("/live/track/start_listen/playing_slot_index", (0,)) - assert client.await_message("/live/track/get/playing_slot_index", TICK_DURATION * 2) == (0, -1,) - client.send_message("/live/track/start_listen/playing_slot_index", (1,)) - assert client.await_message("/live/track/get/playing_slot_index", TICK_DURATION * 2) == (1, -1,) - - client.send_message("/live/clip_slot/fire", (0, 0)) - assert client.await_message("/live/track/get/playing_slot_index", TICK_DURATION * 2) == (0, 0,) - - client.send_message("/live/clip_slot/fire", (0, 1)) - assert client.await_message("/live/track/get/playing_slot_index", TICK_DURATION * 2) == (0, 1,) - - client.send_message("/live/clip_slot/fire", (1, 1)) - assert client.await_message("/live/track/get/playing_slot_index", TICK_DURATION * 2) == (1, 1,) - - client.send_message("/live/clip_slot/fire", (1, 0)) - assert client.await_message("/live/track/get/playing_slot_index", TICK_DURATION * 2) == (1, 0,) - - client.send_message("/live/track/stop_listen/playing_slot_index", (0,)) - client.send_message("/live/track/stop_listen/playing_slot_index", (1,)) - - for track_id, clip_id in itertools.product((0, 1), (0, 1)): - client.send_message("/live/clip_slot/delete_clip", (track_id, clip_id)) + try: + client.send_message("/live/song/set/clip_trigger_quantization", (11,)) + for track_id, clip_id in itertools.product((0, 1), (0, 1)): + client.send_message("/live/clip_slot/create_clip", (track_id, clip_id, 4)) + + # -2 = Clip Stop slot fired; -1 = arrangement recording with no session clip playing + # Depending on test order, either may be returned. + client.send_message("/live/track/start_listen/playing_slot_index", (0,)) + msg = client.await_message("/live/track/get/playing_slot_index", TICK_DURATION * 2) + assert msg[0] == 0 and msg[1] in (-1, -2) + client.send_message("/live/track/start_listen/playing_slot_index", (1,)) + msg = client.await_message("/live/track/get/playing_slot_index", TICK_DURATION * 2) + assert msg[0] == 1 and msg[1] in (-1, -2) + + client.send_message("/live/clip_slot/fire", (0, 0)) + assert client.await_message("/live/track/get/playing_slot_index", TICK_DURATION * 2) == (0, 0,) + + client.send_message("/live/clip_slot/fire", (0, 1)) + assert client.await_message("/live/track/get/playing_slot_index", TICK_DURATION * 2) == (0, 1,) + + client.send_message("/live/clip_slot/fire", (1, 1)) + assert client.await_message("/live/track/get/playing_slot_index", TICK_DURATION * 2) == (1, 1,) + + client.send_message("/live/clip_slot/fire", (1, 0)) + assert client.await_message("/live/track/get/playing_slot_index", TICK_DURATION * 2) == (1, 0,) + + client.send_message("/live/track/stop_listen/playing_slot_index", (0,)) + client.send_message("/live/track/stop_listen/playing_slot_index", (1,)) + + finally: + for track_id, clip_id in itertools.product((0, 1), (0, 1)): + client.send_message("/live/clip_slot/delete_clip", (track_id, clip_id)) + client.send_message("/live/song/stop_playing") + + From 9985f045abb4438e11be3f94fc81ea2a7060c312 Mon Sep 17 00:00:00 2001 From: PhotonicVelocity Date: Tue, 10 Feb 2026 12:05:45 -0800 Subject: [PATCH 11/15] Unified mixer props/params. Added track methods/props. Added tests. --- abletonosc/track.py | 113 +++++++++++++++++++---------- tests/test_track.py | 172 ++++++++++++++++++++++++++++++++++++++------ 2 files changed, 228 insertions(+), 57 deletions(-) diff --git a/abletonosc/track.py b/abletonosc/track.py index 25d9ac4..cf56e45 100644 --- a/abletonosc/track.py +++ b/abletonosc/track.py @@ -34,26 +34,46 @@ def track_callback(params: Tuple[Any]): "delete_clip": {"alias": 0, "caller": "track_delete_clip"}, # Shortcut to clip slot delete "stop_all_clips": {"alias": 0, "caller": 1}, "duplicate_clip_to_arrangement": {"alias": 0, "caller": "track_duplicate_clip_to_arrangement"}, + # New + "delete/device": {"alias": 1, "caller": "delete_device"}, + "delete/clip": {"alias": 0, "caller": "track_delete_clip"}, + "delete/arrangement_clip": {"alias": 0, "caller": "track_delete_arrangement_clip"}, + "duplicate/clip_to_arrangement": {"alias": 0, "caller": "track_duplicate_clip_to_arrangement"}, + "stop/all_clips": {"alias": 1, "caller": "stop_all_clips"}, + "create/audio_clip": {"alias": 1, "caller": "create_audio_clip"}, + "create/midi_clip": {"alias": 1, "caller": "create_midi_clip"}, } properties = { "can_be_armed": {"get": 1, "set": 0, "listen": 0}, # listener removed + "can_be_frozen": {"get": 1, "set": 0, "listen": 0}, + "can_show_chains": {"get": 1, "set": 0, "listen": 0}, "fired_slot_index": {"get": 1, "set": 0, "listen": 1}, "has_audio_input": {"get": 1, "set": 0, "listen": 1}, "has_audio_output": {"get": 1, "set": 0, "listen": 1}, "has_midi_input": {"get": 1, "set": 0, "listen": 1}, "has_midi_output": {"get": 1, "set": 0, "listen": 1}, + "input_meter_level": {"get": 1, "set": 0, "listen": 1}, + "input_meter_left": {"get": 1, "set": 0, "listen": 1}, + "input_meter_right": {"get": 1, "set": 0, "listen": 1}, "is_foldable": {"get": 1, "set": 0, "listen": 0}, # listener removed + "is_frozen": {"get": 1, "set": 0, "listen": 1}, "is_grouped": {"get": 1, "set": 0, "listen": 0}, # listener removed + "is_part_of_selection": {"get": 1, "set": 0, "listen": 0}, "is_visible": {"get": 1, "set": 0, "listen": 0}, # listener removed + "muted_via_solo": {"get": 1, "set": 0, "listen": 1}, "output_meter_level": {"get": 1, "set": 0, "listen": 1}, "output_meter_left": {"get": 1, "set": 0, "listen": 1}, "output_meter_right": {"get": 1, "set": 0, "listen": 1}, + "performance_impact": {"get": 1, "set": 0, "listen": 1}, "playing_slot_index": {"get": 1, "set": 0, "listen": 1}, "arm": {"get": 1, "set": 1, "listen": 1}, + "back_to_arranger": {"get": 1, "set": 1, "listen": 1}, "color": {"get": 1, "set": 1, "listen": 1}, "color_index": {"get": 1, "set": 1, "listen": 1}, "current_monitoring_state": {"get": 1, "set": 1, "listen": 1}, "fold_state": {"get": 1, "set": 1, "listen": 0}, # listener removed + "implicit_arm": {"get": 1, "set": 1, "listen": 1}, + "is_showing_chains": {"get": 1, "set": 1, "listen": 1}, "mute": {"get": 1, "set": 1, "listen": 1}, "solo": {"get": 1, "set": 1, "listen": 1}, "name": {"get": 1, "set": 1, "listen": 1}, @@ -93,12 +113,16 @@ def track_duplicate_clip_to_arrangement(track, params): if clip_slot.clip: track.duplicate_clip_to_arrangement(clip_slot.clip, time) - """ - Shortcut to clip slot delete - """ + def track_delete_clip(track, params: Tuple[Any]): clip_index, = params track.clip_slots[clip_index].delete_clip() + + def track_delete_arrangement_clip(track, params): + clip_index, = params + clip = track.arrangement_clips[clip_index] + track.delete_clip(clip) + #-------------------------------------------------------------------------------- @@ -242,6 +266,15 @@ def track_set_input_routing_channel(track, params): "volume": {"get": 1, "set": 1, "listen": 1}, "panning": {"get": 1, "set": 1, "listen": 1}, "send": {"get": "track_get_send", "set": "track_set_send", "listen": 0}, + # New + # "cue_volume": {"get": 1, "set": 1, "listen": 1, "kind": "param"}, # main track only + # "crossfader": {"get": 1, "set": 1, "listen": 1, "kind": "param"}, # main track only + # "song_tempo": {"get": 1, "set": 1, "listen": 1, "kind": "param"}, # main track only + "track_activator": {"get": 1, "set": 1, "listen": 1, "kind": "param"}, + "left_split_stereo": {"get": 1, "set": 1, "listen": 1, "kind": "param"}, + "right_split_stereo": {"get": 1, "set": 1, "listen": 1, "kind": "param"}, + "crossfade_assign": {"get": 1, "set": 1, "listen": 1, "kind": "prop"}, + "panning_mode": {"get": 1, "set": 1, "listen": 1, "kind": "prop"}, } # Still need to fix these @@ -253,27 +286,14 @@ def track_get_send(track, params: Tuple[Any] = ()): def track_set_send(track, params: Tuple[Any] = ()): send_id, value = params track.mixer_device.sends[send_id].value = value - - def track_get_crossfade_assign(track, _params: Tuple[Any] = ()): - return track.mixer_device.crossfade_assign, - - def track_set_crossfade_assign(track, params: Tuple[Any] = ()): - value, = params - track.mixer_device.crossfade_assign = value - - def track_get_panning_mode(track, _params: Tuple[Any] = ()): - return track.mixer_device.panning_mode, - - def track_set_panning_mode(track, params: Tuple[Any] = ()): - value, = params - track.mixer_device.panning_mode = value local_funcs = locals() for prop, spec in mixer_properties.items(): getter_func = spec.get("get") + kind = spec.get("kind", "param") if getter_func in (True, 1): self.osc_server.add_handler("/live/track/get/%s" % prop, - create_track_callback(self._get_mixer_property, prop)) + create_track_callback(self._get_mixer_property, prop, kind)) elif isinstance(getter_func, str): getter = local_funcs[getter_func] self.osc_server.add_handler("/live/track/get/%s" % prop, @@ -282,7 +302,7 @@ def track_set_panning_mode(track, params: Tuple[Any] = ()): setter_func = spec.get("set") if setter_func in (True, 1): self.osc_server.add_handler("/live/track/set/%s" % prop, - create_track_callback(self._set_mixer_property, prop)) + create_track_callback(self._set_mixer_property, prop, kind)) elif isinstance(setter_func, str): setter = local_funcs[setter_func] self.osc_server.add_handler("/live/track/set/%s" % prop, @@ -291,24 +311,34 @@ def track_set_panning_mode(track, params: Tuple[Any] = ()): observable = spec.get("listen") if observable: self.osc_server.add_handler("/live/track/start_listen/%s" % prop, - create_track_callback(self._start_mixer_listen, prop, include_track_id=True)) + create_track_callback(self._start_mixer_listen, prop, kind, include_track_id=True)) self.osc_server.add_handler("/live/track/stop_listen/%s" % prop, - create_track_callback(self._stop_mixer_listen, prop, include_track_id=True)) + create_track_callback(self._stop_mixer_listen, prop, kind, include_track_id=True)) - def _set_mixer_property(self, target, prop, params: Tuple) -> None: - parameter_object = getattr(target.mixer_device, prop) - self.logger.info("Setting property for %s: %s (new value %s)" % (self.class_identifier, prop, params[0])) - parameter_object.value = params[0] + def _set_mixer_property(self, target, prop, kind: str, params: Tuple) -> None: + value = params[0] + self.logger.info("Setting property for %s: %s (new value %s)" % (self.class_identifier, prop, value)) + if kind == "param": + parameter_object = getattr(target.mixer_device, prop) + parameter_object.value = value + else: + setattr(target.mixer_device, prop, value) - def _get_mixer_property(self, target, prop, params: Optional[Tuple] = ()) -> Tuple[Any]: - parameter_object = getattr(target.mixer_device, prop) - self.logger.info("Getting property for %s: %s = %s" % (self.class_identifier, prop, parameter_object.value)) - return parameter_object.value, + def _get_mixer_property(self, target, prop, kind: str, params: Optional[Tuple] = ()) -> Tuple[Any]: + if kind == "param": + value = getattr(target.mixer_device, prop).value + else: + value = getattr(target.mixer_device, prop) + self.logger.info("Getting property for %s: %s = %s" % (self.class_identifier, prop, value)) + return (value,) - def _start_mixer_listen(self, target, prop, params: Optional[Tuple] = ()) -> None: - parameter_object = getattr(target.mixer_device, prop) + def _start_mixer_listen(self, target, prop, kind: str, params: Optional[Tuple] = ()) -> None: + if kind == "param": + parameter_object = getattr(target.mixer_device, prop) + else: + parameter_object = target.mixer_device def property_changed_callback(): - value = parameter_object.value + value = parameter_object.value if kind == "param" else getattr(parameter_object, prop) self.logger.info("Property %s changed of %s %s: %s" % (prop, self.class_identifier, str(params), value)) osc_address = "/live/%s/get/%s" % (self.class_identifier, prop) self.osc_server.send(osc_address, (*params, value,)) @@ -319,20 +349,31 @@ def property_changed_callback(): self.logger.info("Adding listener for %s %s, property: %s" % (self.class_identifier, str(params), prop)) - parameter_object.add_value_listener(property_changed_callback) + if kind == "param": + parameter_object.add_value_listener(property_changed_callback) + else: + add_listener_function = getattr(parameter_object, "add_%s_listener" % prop) + add_listener_function(property_changed_callback) self.listener_functions[listener_key] = property_changed_callback #-------------------------------------------------------------------------------- # Immediately send the current value #-------------------------------------------------------------------------------- property_changed_callback() - def _stop_mixer_listen(self, target, prop, params: Optional[Tuple[Any]] = ()) -> None: - parameter_object = getattr(target.mixer_device, prop) + def _stop_mixer_listen(self, target, prop, kind: str, params: Optional[Tuple[Any]] = ()) -> None: + if kind == "param": + parameter_object = getattr(target.mixer_device, prop) + else: + parameter_object = target.mixer_device listener_key = (prop, tuple(params)) if listener_key in self.listener_functions: self.logger.info("Removing listener for %s %s, property %s" % (self.class_identifier, str(params), prop)) listener_function = self.listener_functions[listener_key] - parameter_object.remove_value_listener(listener_function) + if kind == "param": + parameter_object.remove_value_listener(listener_function) + else: + remove_listener_function = getattr(parameter_object, "remove_%s_listener" % prop) + remove_listener_function(listener_function) del self.listener_functions[listener_key] else: self.logger.warning("No listener function found for property: %s (%s)" % (prop, str(params))) diff --git a/tests/test_track.py b/tests/test_track.py index 1d532ce..8f8a0f7 100644 --- a/tests/test_track.py +++ b/tests/test_track.py @@ -1,36 +1,87 @@ -from . import client, wait_one_tick, TICK_DURATION +from . import client, wait_one_tick, silent_audio_file, TICK_DURATION import pytest import itertools +import math #-------------------------------------------------------------------------------- # Test track properties #-------------------------------------------------------------------------------- -def _test_track_property(client, track_id, property, values): +def _test_track_property(client, track_id, property, values=None): + if values is None: + rv = client.query("/live/track/get/%s" % property, [track_id]) + assert rv[0] == track_id + assert len(rv) >= 2 + return for value in values: print("Testing property %s, value: %s" % (property, value)) client.send_message("/live/track/set/%s" % property, [track_id, value]) wait_one_tick() assert client.query("/live/track/get/%s" % property, [track_id]) == (track_id, value,) -def test_track_property_panning(client): - _test_track_property(client, 2, "panning", [0.5, 0.0]) - -def test_track_property_volume(client): - _test_track_property(client, 2, "volume", [0.5, 1.0]) - -def test_track_property_color(client): - # Only specific colors from the color picker can be used - _test_track_property(client, 2, "color", [0x001AFF2F, 0x001A2F96]) - -def test_track_property_mute(client): - _test_track_property(client, 2, "mute", [1, 0]) - -def test_track_property_solo(client): - _test_track_property(client, 2, "solo", [1, 0]) - -def test_track_property_name(client): - _test_track_property(client, 2, "name", ["Test", "Track"]) +@pytest.mark.parametrize( + "property,values", + [ + # Only specific colors from the color picker can be used + ("color", [0x001AFF2F, 0x001A2F96]), + ("color_index", [1, 2]), + ("arm", [1, 0]), + ("current_monitoring_state", [0, 1]), + ("back_to_arranger", [0]), + ("implicit_arm", [1, 0]), + ("mute", [1, 0]), + ("solo", [1, 0]), + ("name", ["Test", "Track"]), + ], +) +def test_track_properties_get_set(client, property, values): + _test_track_property(client, 2, property, values) + +@pytest.mark.parametrize( + "property", + [ + "can_be_armed", + "can_be_frozen", + "can_show_chains", + "fired_slot_index", + "has_audio_input", + "has_audio_output", + "has_midi_input", + "has_midi_output", + "input_meter_level", + "input_meter_left", + "input_meter_right", + "fold_state", # Group tracks only + "is_foldable", + "is_frozen", + "is_grouped", + "is_part_of_selection", + "is_showing_chains", # Group tracks only + "is_visible", + "muted_via_solo", + "output_meter_level", + "output_meter_left", + "output_meter_right", + "playing_slot_index", + ], +) +def test_track_properties_get_only(client, property): + _test_track_property(client, 2, property, None) + +@pytest.mark.parametrize( + "property,values", + [ + ("panning", [0.5, 0.0]), + ("volume", [0.5, 1.0]), + ("track_activator", [1, 0]), + ("left_split_stereo", [-0.5, 0.5]), + ("right_split_stereo", [-0.5, 0.5]), + ("crossfade_assign", [0, 2]), + ("panning_mode", [0, 1]), + ], +) +def test_track_mixer_properties(client, property, values): + _test_track_property(client, 2, property, values) #-------------------------------------------------------------------------------- # Test track properties - sends @@ -49,7 +100,7 @@ def test_track_get_send(client): # Test track properties - clips #-------------------------------------------------------------------------------- -def test_track_clips(client): +def test_track_midi_clips(client): track_id = 0 client.send_message("/live/clip_slot/create_clip", (track_id, 0, 4)) client.send_message("/live/clip_slot/create_clip", (track_id, 1, 2)) @@ -64,9 +115,88 @@ def test_track_clips(client): 4, 2, None, None, None, None, None, None) + client.send_message("/live/track/delete_clip", (track_id, 0)) + client.send_message("/live/track/delete_clip", (track_id, 1)) + +def test_track_audio_clips(client, silent_audio_file): + track_id = 2 + client.send_message("/live/clip_slot/create/audio_clip", (track_id, 0, str(silent_audio_file))) + client.send_message("/live/clip_slot/create/audio_clip", (track_id, 1, str(silent_audio_file))) + client.send_message("/live/clip/set/name", (track_id, 0, "Alpha")) + client.send_message("/live/clip/set/name", (track_id, 1, "Beta")) + + wait_one_tick() + names = client.query("/live/track/get/clips/name", (track_id,)) + lengths = client.query("/live/track/get/clips/length", (track_id,)) + assert names[:3] == (track_id, "Alpha", "Beta") + assert lengths[0] == track_id + assert lengths[1] is not None + assert lengths[2] is not None + client.send_message("/live/clip_slot/delete_clip", (track_id, 0)) client.send_message("/live/clip_slot/delete_clip", (track_id, 1)) +#-------------------------------------------------------------------------------- +# Test track methods - duplicate clip to arrangement +#-------------------------------------------------------------------------------- + +def test_track_duplicate_clip_to_arrangement(client): + track_id = 0 + clip_id = 0 + start_time = 0.0 + client.send_message("/live/clip_slot/create/midi_clip", (track_id, clip_id, 4.0)) + client.send_message("/live/clip/set/name", (track_id, clip_id, "Alpha")) + wait_one_tick() + + client.send_message("/live/track/duplicate/clip_to_arrangement", (track_id, clip_id, start_time)) + wait_one_tick() + + names = client.query("/live/track/get/arrangement_clips/name", (track_id,)) + times = client.query("/live/track/get/arrangement_clips/start_time", (track_id,)) + assert "Alpha" in names[1:] + assert any(t is not None and math.isclose(t, start_time, abs_tol=1e-4) for t in times[1:]) + + client.send_message("/live/clip_slot/delete_clip", (track_id, clip_id)) + client.send_message("/live/track/delete/arrangement_clip", (track_id, clip_id)) + +#-------------------------------------------------------------------------------- +# Test track methods - create arrangment clips +#-------------------------------------------------------------------------------- + +def test_track_create_arrangement_midi_clip(client): + track_id = 0 + start_time = 0.0 + length = 4.0 + try: + client.send_message("/live/track/create/midi_clip", (track_id, start_time, length)) + wait_one_tick() + times = client.query("/live/track/get/arrangement_clips/start_time", (track_id,))[1:] + lengths = client.query("/live/track/get/arrangement_clips/length", (track_id,))[1:] + assert any(t is not None and math.isclose(t, start_time, abs_tol=1e-4) for t in times) + assert any(l is not None and math.isclose(l, length, abs_tol=1e-4) for l in lengths) + finally: + times = client.query("/live/track/get/arrangement_clips/start_time", (track_id,))[1:] + for i, t in enumerate(times): + if t is not None and math.isclose(t, start_time, abs_tol=1e-4): + client.send_message("/live/track/delete/arrangement_clip", (track_id, i)) + break + +def test_track_create_arrangement_audio_clip(client, silent_audio_file): + track_id = 2 + start_time = 2.0 + try: + client.send_message("/live/track/create/audio_clip", (track_id, str(silent_audio_file), start_time)) + wait_one_tick() + times = client.query("/live/track/get/arrangement_clips/start_time", (track_id,))[1:] + assert any(t is not None and math.isclose(t, start_time, abs_tol=1e-4) for t in times) + finally: + times = client.query("/live/track/get/arrangement_clips/start_time", (track_id,))[1:] + for i, t in enumerate(times): + if t is not None and math.isclose(t, start_time, abs_tol=1e-4): + client.send_message("/live/track/delete/arrangement_clip", (track_id, i)) + break + + #-------------------------------------------------------------------------------- # Test track properties - devices #-------------------------------------------------------------------------------- From 3246c5b42e8ba2b68cbe5c726f9899ee994a1b1a Mon Sep 17 00:00:00 2001 From: PhotonicVelocity Date: Tue, 10 Feb 2026 12:20:23 -0800 Subject: [PATCH 12/15] add routing properties --- abletonosc/track.py | 51 +++++++++++++++++++++++++++++++++++++++++---- tests/test_track.py | 40 +++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 4 deletions(-) diff --git a/abletonosc/track.py b/abletonosc/track.py index cf56e45..0ecf195 100644 --- a/abletonosc/track.py +++ b/abletonosc/track.py @@ -99,6 +99,14 @@ def track_callback(params: Tuple[Any]): "available_input_routing_channels": {"get": "track_get_available_input_routing_channels", "set": 0, "listen": 0}, "input_routing_type": {"get": "track_get_input_routing_type", "set": "track_set_input_routing_type", "listen": 0}, "input_routing_channel": {"get": "track_get_input_routing_channel", "set": "track_set_input_routing_channel", "listen": 0}, + "input_routings": {"get": "track_get_input_routings", "set": 0, "listen": 0}, + "input_sub_routings": {"get": "track_get_input_sub_routings", "set": 0, "listen": 0}, + "current_input_routing": {"get": "track_get_current_input_routing", "set": "track_set_current_input_routing", "listen": 0}, + "current_input_sub_routing": {"get": "track_get_current_input_sub_routing", "set": "track_set_current_input_sub_routing", "listen": 0}, + "output_routings": {"get": "track_get_output_routings", "set": 0, "listen": 0}, + "output_sub_routings": {"get": "track_get_output_sub_routings", "set": 0, "listen": 0}, + "current_output_routing": {"get": "track_get_current_output_routing", "set": "track_set_current_output_routing", "listen": 0}, + "current_output_sub_routing": {"get": "track_get_current_output_sub_routing", "set": "track_set_current_output_sub_routing", "listen": 0}, } @@ -164,10 +172,17 @@ def track_get_device_can_have_chains(track, _): # Since Live 10, both of these need to be set by reference to the appropriate # item in the available_output_routing_types vector. #-------------------------------------------------------------------------------- + def _routing_display_names(items) -> Tuple[str, ...]: + names = [] + for item in items: + name = getattr(item, "display_name", None) + names.append(name if name is not None else str(item)) + return tuple(names) + def track_get_available_output_routing_types(track, _): - return tuple([routing_type.display_name for routing_type in track.available_output_routing_types]) + return _routing_display_names(track.available_output_routing_types) def track_get_available_output_routing_channels(track, _): - return tuple([routing_channel.display_name for routing_channel in track.available_output_routing_channels]) + return _routing_display_names(track.available_output_routing_channels) def track_get_output_routing_type(track, _): return track.output_routing_type.display_name, def track_set_output_routing_type(track, params): @@ -186,14 +201,28 @@ def track_set_output_routing_channel(track, params): track.output_routing_channel = channel return self.logger.warning("Couldn't find output routing channel: %s" % channel_name) + def track_get_output_routings(track, _): + return _routing_display_names(track.output_routings) + def track_get_output_sub_routings(track, _): + return _routing_display_names(track.output_sub_routings) + def track_get_current_output_routing(track, _): + return track.current_output_routing, + def track_set_current_output_routing(track, params): + routing_name = str(params[0]) + track.current_output_routing = routing_name + def track_get_current_output_sub_routing(track, _): + return track.current_output_sub_routing, + def track_set_current_output_sub_routing(track, params): + routing_name = str(params[0]) + track.current_output_sub_routing = routing_name #-------------------------------------------------------------------------------- # Track: Input routing. #-------------------------------------------------------------------------------- def track_get_available_input_routing_types(track, _): - return tuple([routing_type.display_name for routing_type in track.available_input_routing_types]) + return _routing_display_names(track.available_input_routing_types) def track_get_available_input_routing_channels(track, _): - return tuple([routing_channel.display_name for routing_channel in track.available_input_routing_channels]) + return _routing_display_names(track.available_input_routing_channels) def track_get_input_routing_type(track, _): return track.input_routing_type.display_name, def track_set_input_routing_type(track, params): @@ -212,6 +241,20 @@ def track_set_input_routing_channel(track, params): track.input_routing_channel = channel return self.logger.warning("Couldn't find input routing channel: %s" % channel_name) + def track_get_input_routings(track, _): + return _routing_display_names(track.input_routings) + def track_get_input_sub_routings(track, _): + return _routing_display_names(track.input_sub_routings) + def track_get_current_input_routing(track, _): + return track.current_input_routing, + def track_set_current_input_routing(track, params): + routing_name = str(params[0]) + track.current_input_routing = routing_name + def track_get_current_input_sub_routing(track, _): + return track.current_input_sub_routing, + def track_set_current_input_sub_routing(track, params): + routing_name = str(params[0]) + track.current_input_sub_routing = routing_name # Add Handlers local_funcs = locals() diff --git a/tests/test_track.py b/tests/test_track.py index 8f8a0f7..5b81933 100644 --- a/tests/test_track.py +++ b/tests/test_track.py @@ -96,6 +96,46 @@ def test_track_get_send(client): wait_one_tick() assert client.query("/live/track/get/send", (track_id, send_id)) == (track_id, send_id, value,) +#-------------------------------------------------------------------------------- +# Test track properties - routing +#-------------------------------------------------------------------------------- + +def _test_track_routing_current(client, track_id, property): + rv = client.query("/live/track/get/%s" % property, [track_id]) + assert rv[0] == track_id + if len(rv) < 2 or rv[1] in (None, ""): + return + client.send_message("/live/track/set/%s" % property, [track_id, rv[1]]) + wait_one_tick() + assert client.query("/live/track/get/%s" % property, [track_id]) == (track_id, rv[1],) + +@pytest.mark.parametrize( + "property", + [ + "input_routings", + "input_sub_routings", + "output_routings", + "output_sub_routings", + ], +) +def test_track_routing_lists(client, property): + track_id = 2 + rv = client.query("/live/track/get/%s" % property, [track_id]) + assert rv[0] == track_id + +@pytest.mark.parametrize( + "property", + [ + "current_input_routing", + "current_input_sub_routing", + "current_output_routing", + "current_output_sub_routing", + ], +) +def test_track_routing_current(client, property): + track_id = 2 + _test_track_routing_current(client, track_id, property) + #-------------------------------------------------------------------------------- # Test track properties - clips #-------------------------------------------------------------------------------- From db4c36bd7beec5a9f41a06fa0944f0f6cc7c9ddb Mon Sep 17 00:00:00 2001 From: PhotonicVelocity Date: Tue, 10 Feb 2026 12:41:48 -0800 Subject: [PATCH 13/15] added track view properties --- abletonosc/__init__.py | 2 +- abletonosc/track.py | 66 ++++++++++++++++++++++++++++++++++++++++++ manager.py | 1 + tests/test_track.py | 23 +++++++++++++++ 4 files changed, 91 insertions(+), 1 deletion(-) diff --git a/abletonosc/__init__.py b/abletonosc/__init__.py index 53ba155..4ca8c6b 100644 --- a/abletonosc/__init__.py +++ b/abletonosc/__init__.py @@ -8,7 +8,7 @@ from .song import SongHandler from .clip import ClipHandler from .clip_slot import ClipSlotHandler -from .track import TrackHandler +from .track import TrackHandler, TrackViewHandler from .device import DeviceHandler from .scene import SceneHandler from .view import ViewHandler diff --git a/abletonosc/track.py b/abletonosc/track.py index 0ecf195..3cffdd6 100644 --- a/abletonosc/track.py +++ b/abletonosc/track.py @@ -420,3 +420,69 @@ def _stop_mixer_listen(self, target, prop, kind: str, params: Optional[Tuple[Any del self.listener_functions[listener_key] else: self.logger.warning("No listener function found for property: %s (%s)" % (prop, str(params))) + + +class TrackViewHandler(AbletonOSCHandler): + def __init__(self, manager): + super().__init__(manager) + self.class_identifier = "track_view" + + def init_api(self): + def create_track_view_callback(func: Callable, + *args, + pass_track_index: bool = False): + def view_callback(params: Tuple[Any]) -> Tuple: + if params[0] == "*": + track_indices = list(range(len(self.song.tracks))) + else: + track_indices = [int(params[0])] + + for track_index in track_indices: + track = self.song.tracks[track_index] + view = track.view + if pass_track_index: + rv = func(view, *args, tuple(params[0:])) + else: + rv = func(view, *args, tuple(params[1:])) + if rv is not None: + return (track_index, *rv) + + return view_callback + + methods = { + # No methods yet + } + + properties = { + "canonical_parent": {"get": 0, "set": 0, "listen": 0}, # Track object, not serializable + "device_insert_mode": {"get": 1, "set": 0, "listen": 0}, # Get/Listen only per API + "is_collapsed": {"get": 1, "set": 1, "listen": 0}, + "selected_device": {"get": 0, "set": 0, "listen": 0}, # Device object, not serializable + } + + for method, spec in methods.items(): + caller = spec.get("caller") + alias = spec.get("alias") + if not caller: + continue + target = caller if alias else method + self.osc_server.add_handler("/live/track/view/%s" % method, + create_track_view_callback(self._call_method, target)) + + for prop, spec in properties.items(): + getter_func = spec.get("get") + if getter_func: + self.osc_server.add_handler("/live/track/view/get/%s" % prop, + create_track_view_callback(self._get_property, prop)) + + setter_func = spec.get("set") + if setter_func: + self.osc_server.add_handler("/live/track/view/set/%s" % prop, + create_track_view_callback(self._set_property, prop)) + + listen_func = spec.get("listen") + if listen_func: + self.osc_server.add_handler("/live/track/view/start_listen/%s" % prop, + create_track_view_callback(self._start_listen, prop, pass_track_index=True)) + self.osc_server.add_handler("/live/track/view/stop_listen/%s" % prop, + create_track_view_callback(self._stop_listen, prop, pass_track_index=True)) diff --git a/manager.py b/manager.py index 94753c4..730ec3b 100644 --- a/manager.py +++ b/manager.py @@ -96,6 +96,7 @@ def show_message_callback(params): abletonosc.ClipHandler(self), abletonosc.ClipSlotHandler(self), abletonosc.TrackHandler(self), + abletonosc.TrackViewHandler(self), abletonosc.DeviceHandler(self), abletonosc.ViewHandler(self), abletonosc.SceneHandler(self), diff --git a/tests/test_track.py b/tests/test_track.py index 5b81933..fc07958 100644 --- a/tests/test_track.py +++ b/tests/test_track.py @@ -136,6 +136,29 @@ def test_track_routing_current(client, property): track_id = 2 _test_track_routing_current(client, track_id, property) +#-------------------------------------------------------------------------------- +# Test track view properties +#-------------------------------------------------------------------------------- + +def _toggle_view_property(client, track_id, prop): + msg = client.query("/live/track/view/get/%s" % prop, (track_id,)) + assert msg[0] == track_id + current = msg[1] + if current in (0, 1, True, False): + new_value = 0 if current else 1 + else: + new_value = current + client.send_message("/live/track/view/set/%s" % prop, (track_id, new_value)) + wait_one_tick() + msg = client.query("/live/track/view/get/%s" % prop, (track_id,)) + assert msg == (track_id, new_value) + +def test_track_view_properties(client): + track_id = 2 + msg = client.query("/live/track/view/get/device_insert_mode", (track_id,)) + assert msg[0] == track_id + _toggle_view_property(client, track_id, "is_collapsed") + #-------------------------------------------------------------------------------- # Test track properties - clips #-------------------------------------------------------------------------------- From c487dff1b9aec5eddc216cc088885de48d05e7d3 Mon Sep 17 00:00:00 2001 From: PhotonicVelocity Date: Tue, 10 Feb 2026 13:46:09 -0800 Subject: [PATCH 14/15] add duplicate/clip_slot and jump_in_running_session_clip --- abletonosc/track.py | 2 ++ tests/test_track.py | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/abletonosc/track.py b/abletonosc/track.py index 3cffdd6..a3af8a2 100644 --- a/abletonosc/track.py +++ b/abletonosc/track.py @@ -39,9 +39,11 @@ def track_callback(params: Tuple[Any]): "delete/clip": {"alias": 0, "caller": "track_delete_clip"}, "delete/arrangement_clip": {"alias": 0, "caller": "track_delete_arrangement_clip"}, "duplicate/clip_to_arrangement": {"alias": 0, "caller": "track_duplicate_clip_to_arrangement"}, + "duplicate/clip_slot": {"alias": 1, "caller": "duplicate_clip_slot"}, "stop/all_clips": {"alias": 1, "caller": "stop_all_clips"}, "create/audio_clip": {"alias": 1, "caller": "create_audio_clip"}, "create/midi_clip": {"alias": 1, "caller": "create_midi_clip"}, + "jump_in_running_session_clip": {"alias": 0, "caller": 1}, } properties = { "can_be_armed": {"get": 1, "set": 0, "listen": 0}, # listener removed diff --git a/tests/test_track.py b/tests/test_track.py index fc07958..0b1cc58 100644 --- a/tests/test_track.py +++ b/tests/test_track.py @@ -222,6 +222,29 @@ def test_track_duplicate_clip_to_arrangement(client): client.send_message("/live/clip_slot/delete_clip", (track_id, clip_id)) client.send_message("/live/track/delete/arrangement_clip", (track_id, clip_id)) +#-------------------------------------------------------------------------------- +# Test track methods - duplicate clip slot +#-------------------------------------------------------------------------------- + +def test_track_duplicate_clip_slot(client): + track_id = 0 + source_clip_id = 0 + target_clip_id = 1 + try: + client.send_message("/live/clip_slot/create_clip", (track_id, source_clip_id, 4.0)) + wait_one_tick() + client.send_message("/live/clip/set/name", (track_id, source_clip_id, "Alpha")) + wait_one_tick() + + client.send_message("/live/track/duplicate/clip_slot", (track_id, source_clip_id)) + wait_one_tick() + + name = client.query("/live/clip/get/name", (track_id, target_clip_id)) + assert name == (track_id, target_clip_id, "Alpha") + finally: + client.send_message("/live/clip_slot/delete_clip", (track_id, source_clip_id)) + client.send_message("/live/clip_slot/delete_clip", (track_id, target_clip_id)) + #-------------------------------------------------------------------------------- # Test track methods - create arrangment clips #-------------------------------------------------------------------------------- @@ -244,6 +267,22 @@ def test_track_create_arrangement_midi_clip(client): client.send_message("/live/track/delete/arrangement_clip", (track_id, i)) break +#-------------------------------------------------------------------------------- +# Test track methods - jump in running session clip +#-------------------------------------------------------------------------------- + +def test_track_jump_in_running_session_clip(client): + track_id = 0 + clip_id = 0 + try: + client.send_message("/live/clip_slot/create_clip", (track_id, clip_id, 4.0)) + client.send_message("/live/clip_slot/fire", (track_id, clip_id)) + wait_one_tick() + client.send_message("/live/track/jump_in_running_session_clip", (track_id, 0.5)) + wait_one_tick() + finally: + client.send_message("/live/clip_slot/delete_clip", (track_id, clip_id)) + def test_track_create_arrangement_audio_clip(client, silent_audio_file): track_id = 2 start_time = 2.0 From 5000bf38735d9aaf8cdbc0a13b7b6b0ca1fbfd00 Mon Sep 17 00:00:00 2001 From: PhotonicVelocity Date: Tue, 10 Feb 2026 14:07:56 -0800 Subject: [PATCH 15/15] updated readme --- README.md | 272 ++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 202 insertions(+), 70 deletions(-) diff --git a/README.md b/README.md index 50fe71d..01b04a3 100644 --- a/README.md +++ b/README.md @@ -231,11 +231,17 @@ To query the properties of multiple tracks, see [Song: Properties of cue points, ### Track methods -| Address | Query params | Response params | Description | -|:------------------------------|:----------------------------------|:----------------|:----------------------------------------------------------------------------| -| /live/track/stop_all_clips | track_id | | Stop all clips on track | -| /live/track/create_audio_clip | track_id, file_path, position | | Add sample at absolute file path as audio clip to track in arrangement view | -| /live/track/create_midi_clip | track_id, position, length | | Add midi clip to track in arrangement view | +| Address | Query params | Response params | Description | +| :---------------------------------------- | :------------------------------------------ | :---------------------------- | :-------------------------------------------------------------------------------------- | +| /live/track/create/audio_clip | track_id, file_path, position | | Create an arrangement audio clip from an absolute file path at the given position. | +| /live/track/create/midi_clip | track_id, start_time, length | | Create an empty MIDI clip in arrangement at the given time and length. | +| /live/track/delete/arrangement_clip | track_id, arrangement_clip_index | | Delete an arrangement clip by index in track.arrangement_clips. | +| /live/track/delete/clip | track_id, clip_slot_index | | Delete a session clip in the given clip slot. | +| /live/track/delete/device | track_id, device_index | | Delete device at the given index in the device chain. | +| /live/track/duplicate/clip_slot | track_id, clip_slot_index | track_id, new_clip_slot_index | Duplicate the clip in the given slot to the next free slot (returns destination index). | +| /live/track/duplicate/clip_to_arrangement | track_id, clip_slot_index, destination_time | | Duplicate the session clip in the given slot to arrangement at destination_time. | +| /live/track/jump_in_running_session_clip | track_id, beats | | Jump forward/backward in the currently running session clip by the given beat offset. | +| /live/track/stop/all_clips | track_id | | Stops all playing/fired clips on the track. | ### Track properties @@ -244,79 +250,205 @@ To query the properties of multiple tracks, see [Song: Properties of cue points, #### Getters -| Address | Query params | Response params | Description | -|:--------------------------------------------------|:------------------|:---------------------------|:--------------------------------------------------| -| /live/track/get/arm | track_id | track_id, armed | Query whether track is armed | -| /live/track/get/available_input_routing_channels | track_id | track_id, channel, ... | List input channels (e.g. "1", "2", "1/2", ...) | -| /live/track/get/available_input_routing_types | track_id | track_id, type, ... | List input routes (e.g. "Ext. In", ...) | -| /live/track/get/available_output_routing_channels | track_id | track_id, channel, ... | List output channels (e.g. "1", "2", "1/2", ...) | -| /live/track/get/available_output_routing_types | track_id | track_id, type, ... | List output routes (e.g. "Ext. Out", ...) | -| /live/track/get/can_be_armed | track_id | track_id, can_be_armed | Query whether track can be armed | -| /live/track/get/color | track_id | track_id, color | Query track color | -| /live/track/get/color_index | track_id | track_id, color_index | Query track color index | -| /live/track/get/current_monitoring_state | track_id | track_id, state | Query current monitoring state (1=on, 0=off) | -| /live/track/get/fired_slot_index | track_id | track_id, index | Query currently-fired slot | -| /live/track/get/fold_state | track_id | track_id, fold_state | Query folded state (for groups) | -| /live/track/get/has_audio_input | track_id | track_id, has_audio_input | Query has_audio_input | -| /live/track/get/has_audio_output | track_id | track_id, has_audio_output | Query has_audio_output | -| /live/track/get/has_midi_input | track_id | track_id, has_midi_input | Query has_midi_input | -| /live/track/get/has_midi_output | track_id | track_id, has_midi_output | Query has_midi_output | -| /live/track/get/input_routing_channel | track_id | track_id, channel | Query current input routing channel | -| /live/track/get/input_routing_type | track_id | track_id, type | Query current input routing type | -| /live/track/get/output_routing_channel | track_id | track_id, channel | Query current output routing channel | -| /live/track/get/output_meter_left | track_id | track_id, level | Query current output level, left channel | -| /live/track/get/output_meter_level | track_id | track_id, level | Query current output level, both channels | -| /live/track/get/output_meter_right | track_id | track_id, level | Query current output level, right channel | -| /live/track/get/output_routing_type | track_id | track_id, type | Query current output routing type | -| /live/track/get/is_foldable | track_id | track_id, is_foldable | Query whether track is foldable, i.e. is a group | -| /live/track/get/is_grouped | track_id | track_id, is_grouped | Query whether track is in a group | -| /live/track/get/is_visible | track_id | track_id, is_visible | Query whether track is visible (1=on, 0=off) | -| /live/track/get/mute | track_id | track_id, mute | Query track mute (1=on, 0=off) | -| /live/track/get/name | track_id | track_id, name | Query track name | -| /live/track/get/panning | track_id | track_id, panning | Query track panning | -| /live/track/get/playing_slot_index | track_id | track_id, index | Query currently-playing slot | -| /live/track/get/send | track_id, send_id | track_id, send_id, value | Query track send | -| /live/track/get/solo | track_id | track_id, solo | Query track solo on/off | -| /live/track/get/volume | track_id | track_id, volume | Query track volume | +| Address | Query params | Response params | Description | +| :------------------------------------------------ | :---------------- | :-------------------------- | :-------------------------------------------------------------------------------------------- | +| /live/track/get/arm | track_id | track_id, armed | Query whether track is armed | +| /live/track/get/arrangement_clips/length | track_id | track_id, [length, ...] | Query all arrangement view clip lengths on track | +| /live/track/get/arrangement_clips/name | track_id | track_id, [name, ....] | Query all arrangement view clip names on track | +| /live/track/get/arrangement_clips/start_time | track_id | track_id, [start_time, ...] | Query all arrangement view clip times on track | +| /live/track/get/available_input_routing_channels | track_id | track_id, channel, ... | List input channels (e.g. "1", "2", "1/2", ...) | +| /live/track/get/available_input_routing_types | track_id | track_id, type, ... | List input routes (e.g. "Ext. In", ...) | +| /live/track/get/available_output_routing_channels | track_id | track_id, channel, ... | List output channels (e.g. "1", "2", "1/2", ...) | +| /live/track/get/available_output_routing_types | track_id | track_id, type, ... | List output routes (e.g. "Ext. Out", ...) | +| /live/track/get/back_to_arranger | track_id | track_id, value | Single Track Back to Arrangement state (1=lit). Setting to 0 returns to arrangement playback. | +| /live/track/get/can_be_armed | track_id | track_id, can_be_armed | Query whether track can be armed | +| /live/track/get/can_be_frozen | track_id | track_id, value | 1 if track can be frozen. | +| /live/track/get/can_show_chains | track_id | track_id, value | 1 if track has an Instrument Rack that can show chains. | +| /live/track/get/clips/color | track_id | track_id, [color, ...] | Query all clip colors on track | +| /live/track/get/clips/length | track_id | track_id, [length, ...] | Query all clip lengths on track | +| /live/track/get/clips/name | track_id | track_id, [name, ....] | Query all clip names on track | +| /live/track/get/color | track_id | track_id, color | Query track color | +| /live/track/get/color_index | track_id | track_id, color_index | Query track color index | +| /live/track/get/crossfade_assign | track_id | track_id, value | Crossfader assignment (A/B/None). | +| /live/track/get/current_input_routing | track_id | track_id, value | Current input routing name. | +| /live/track/get/current_input_sub_routing | track_id | track_id, value | Current input sub‑routing name. | +| /live/track/get/current_monitoring_state | track_id | track_id, state | Query current monitoring state (1=on, 0=off) | +| /live/track/get/current_output_routing | track_id | track_id, value | Current output routing name. | +| /live/track/get/current_output_sub_routing | track_id | track_id, value | Current output sub‑routing name. | +| /live/track/get/devices/can_have_chains | track_id | track_id, [bool, ...] | Query whether devices can have chains | +| /live/track/get/devices/class_name | track_id | track_id, [class, ...] | Query all device class names on track | +| /live/track/get/devices/name | track_id | track_id, [name, ...] | Query all device names on track | +| /live/track/get/devices/type | track_id | track_id, [type, ...] | Query all devices types on track | +| /live/track/get/fired_slot_index | track_id | track_id, index | Query currently-fired slot | +| /live/track/get/fold_state | track_id | track_id, fold_state | Query folded state (for groups) | +| /live/track/get/has_audio_input | track_id | track_id, has_audio_input | Query has_audio_input | +| /live/track/get/has_audio_output | track_id | track_id, has_audio_output | Query has_audio_output | +| /live/track/get/has_midi_input | track_id | track_id, has_midi_input | Query has_midi_input | +| /live/track/get/has_midi_output | track_id | track_id, has_midi_output | Query has_midi_output | +| /live/track/get/implicit_arm | track_id | track_id, value | Secondary arm state used by Push. | +| /live/track/get/input_meter_left | track_id | track_id, value | Input meter left channel peak (0.0–1.0). | +| /live/track/get/input_meter_level | track_id | track_id, value | Input meter hold peak (0.0–1.0). | +| /live/track/get/input_meter_right | track_id | track_id, value | Input meter right channel peak (0.0–1.0). | +| /live/track/get/input_routing_channel | track_id | track_id, channel | Query current input routing channel | +| /live/track/get/input_routing_type | track_id | track_id, type | Query current input routing type | +| /live/track/get/input_routings | track_id | track_id, value | List of available input routings (display names). | +| /live/track/get/input_sub_routings | track_id | track_id, value | List of available input sub‑routings (display names). | +| /live/track/get/is_foldable | track_id | track_id, is_foldable | Query whether track is foldable, i.e. is a group | +| /live/track/get/is_frozen | track_id | track_id, value | 1 if track is currently frozen. | +| /live/track/get/is_grouped | track_id | track_id, is_grouped | Query whether track is in a group | +| /live/track/get/is_part_of_selection | track_id | track_id, value | 1 if track is part of current selection. | +| /live/track/get/is_showing_chains | track_id | track_id, value | Whether Instrument Rack chains are shown in Session View. | +| /live/track/get/is_visible | track_id | track_id, is_visible | Query whether track is visible (1=on, 0=off) | +| /live/track/get/left_split_stereo | track_id | track_id, value | Left split stereo panning parameter. | +| /live/track/get/mute | track_id | track_id, mute | Query track mute (1=on, 0=off) | +| /live/track/get/muted_via_solo | track_id | track_id, value | 1 if track is muted because another track is soloed. | +| /live/track/get/name | track_id | track_id, name | Query track name | +| /live/track/get/num_devices | track_id | track_id, num_devices | Query the number of devices on the track | +| /live/track/get/output_meter_left | track_id | track_id, level | Query current output level, left channel | +| /live/track/get/output_meter_level | track_id | track_id, level | Query current output level, both channels | +| /live/track/get/output_meter_right | track_id | track_id, level | Query current output level, right channel | +| /live/track/get/output_routing_channel | track_id | track_id, channel | Query current output routing channel | +| /live/track/get/output_routing_type | track_id | track_id, type | Query current output routing type | +| /live/track/get/output_routings | track_id | track_id, value | List of available output routings (display names). | +| /live/track/get/output_sub_routings | track_id | track_id, value | List of available output sub‑routings (display names). | +| /live/track/get/panning | track_id | track_id, panning | Query track panning | +| /live/track/get/panning_mode | track_id | track_id, value | Track panning mode. | +| /live/track/get/performance_impact | track_id | track_id, value | Performance impact of this track. | +| /live/track/get/playing_slot_index | track_id | track_id, index | Query currently-playing slot | +| /live/track/get/right_split_stereo | track_id | track_id, value | Right split stereo panning parameter. | +| /live/track/get/send | track_id, send_id | track_id, send_id, value | Query track send amount. | +| /live/track/get/solo | track_id | track_id, solo | Query track solo on/off | +| /live/track/get/track_activator | track_id | track_id, value | Track activator (on/off). | +| /live/track/get/volume | track_id | track_id, volume | Query track volume | #### Setters -| Address | Query params | Response params | Description | -|:-----------------------------------------|:-------------------------|:----------------|:----------------------------------| -| /live/track/set/arm | track_id, armed | | Set track arm state (1=on, 0=off) | -| /live/track/set/color | track_id, color | | Set track color | -| /live/track/set/color_index | track_id, color_index | | Set track color index | -| /live/track/set/current_monitoring_state | track_id, state | | Set monitoring on/off | -| /live/track/set/fold_state | track_id, fold_state | | Set group folded (1=on, 0=off) | -| /live/track/set/input_routing_channel | track_id, channel | | Set input routing channel | -| /live/track/set/input_routing_type | track_id, type | | Set input routing type | -| /live/track/set/mute | track_id, mute | | Set track mute (1=on, 0=off) | -| /live/track/set/name | track_id, name | | Set track name | -| /live/track/set/output_routing_channel | track_id, channel | | Set output routing channel | -| /live/track/set/output_routing_type | track_id, type | | Set output routing type | -| /live/track/set/panning | track_id, panning | | Set track panning | -| /live/track/set/send | track_id, send_id, value | | Set track send | -| /live/track/set/solo | track_id, solo | | Set track solo (1=on, 0=off) | -| /live/track/set/volume | track_id, volume | | Set track volume | +| Address | Query params | Response params | Description | +| :----------------------------------------- | :----------------------- | :-------------- | :-------------------------------------------------------------------------------------------- | +| /live/track/set/arm | track_id, armed | | Set track arm state (1=on, 0=off) | +| /live/track/set/back_to_arranger | track_id, value | | Single Track Back to Arrangement state (1=lit). Setting to 0 returns to arrangement playback. | +| /live/track/set/color | track_id, color | | Set track color | +| /live/track/set/color_index | track_id, color_index | | Set track color index | +| /live/track/set/crossfade_assign | track_id, value | | Crossfader assignment (A/B/None). | +| /live/track/set/current_input_routing | track_id, value | | Current input routing name. | +| /live/track/set/current_input_sub_routing | track_id, value | | Current input sub‑routing name. | +| /live/track/set/current_monitoring_state | track_id, state | | Set monitoring on/off | +| /live/track/set/current_output_routing | track_id, value | | Current output routing name. | +| /live/track/set/current_output_sub_routing | track_id, value | | Current output sub‑routing name. | +| /live/track/set/fold_state | track_id, fold_state | | Set group folded (1=on, 0=off) | +| /live/track/set/implicit_arm | track_id, value | | Secondary arm state used by Push. | +| /live/track/set/input_routing_channel | track_id, channel | | Set input routing channel | +| /live/track/set/input_routing_type | track_id, type | | Set input routing type | +| /live/track/set/is_showing_chains | track_id, value | | Whether Instrument Rack chains are shown in Session View. | +| /live/track/set/left_split_stereo | track_id, value | | Left split stereo panning parameter. | +| /live/track/set/mute | track_id, mute | | Set track mute (1=on, 0=off) | +| /live/track/set/name | track_id, name | | Set track name | +| /live/track/set/output_routing_channel | track_id, channel | | Set output routing channel | +| /live/track/set/output_routing_type | track_id, type | | Set output routing type | +| /live/track/set/panning | track_id, panning | | Set track panning | +| /live/track/set/panning_mode | track_id, value | | Track panning mode. | +| /live/track/set/right_split_stereo | track_id, value | | Right split stereo panning parameter. | +| /live/track/set/send | track_id, send_id, value | | Set track send amount. | +| /live/track/set/solo | track_id, solo | | Set track solo (1=on, 0=off) | +| /live/track/set/track_activator | track_id, value | | Track activator (on/off). | +| /live/track/set/volume | track_id, volume | | Set track volume | + +#### Listeners + +| Address | Query params | Response params | Description | +| :------------------------------------------------ | :----------- | :-------------- | :-------------------------------------------------------------------------------------------- | +| /live/track/start_listen/arm | track_id | track_id, value | Query whether track is armed | +| /live/track/start_listen/back_to_arranger | track_id | track_id, value | Single Track Back to Arrangement state (1=lit). Setting to 0 returns to arrangement playback. | +| /live/track/start_listen/color | track_id | track_id, value | Query track color | +| /live/track/start_listen/color_index | track_id | track_id, value | Query track color index | +| /live/track/start_listen/crossfade_assign | track_id | track_id, value | Crossfader assignment (A/B/None). | +| /live/track/start_listen/current_monitoring_state | track_id | track_id, value | Query current monitoring state (1=on, 0=off) | +| /live/track/start_listen/fired_slot_index | track_id | track_id, value | Blinking slot index (-1 none, -2 clip stop). | +| /live/track/start_listen/has_audio_input | track_id | track_id, value | Query has_audio_input | +| /live/track/start_listen/has_audio_output | track_id | track_id, value | Query has_audio_output | +| /live/track/start_listen/has_midi_input | track_id | track_id, value | Query has_midi_input | +| /live/track/start_listen/has_midi_output | track_id | track_id, value | Query has_midi_output | +| /live/track/start_listen/implicit_arm | track_id | track_id, value | Secondary arm state used by Push. | +| /live/track/start_listen/input_meter_left | track_id | track_id, value | Input meter left channel peak (0.0–1.0). | +| /live/track/start_listen/input_meter_level | track_id | track_id, value | Input meter hold peak (0.0–1.0). | +| /live/track/start_listen/input_meter_right | track_id | track_id, value | Input meter right channel peak (0.0–1.0). | +| /live/track/start_listen/is_frozen | track_id | track_id, value | 1 if track is currently frozen. | +| /live/track/start_listen/is_showing_chains | track_id | track_id, value | Whether Instrument Rack chains are shown in Session View. | +| /live/track/start_listen/left_split_stereo | track_id | track_id, value | Left split stereo panning parameter. | +| /live/track/start_listen/mute | track_id | track_id, value | Query track mute (1=on, 0=off) | +| /live/track/start_listen/muted_via_solo | track_id | track_id, value | 1 if track is muted because another track is soloed. | +| /live/track/start_listen/name | track_id | track_id, value | Query track name | +| /live/track/start_listen/output_meter_left | track_id | track_id, value | Query current output level, left channel | +| /live/track/start_listen/output_meter_level | track_id | track_id, value | Query current output level, both channels | +| /live/track/start_listen/output_meter_right | track_id | track_id, value | Query current output level, right channel | +| /live/track/start_listen/panning | track_id | track_id, value | Query track panning | +| /live/track/start_listen/panning_mode | track_id | track_id, value | Track panning mode. | +| /live/track/start_listen/performance_impact | track_id | track_id, value | Performance impact of this track. | +| /live/track/start_listen/playing_slot_index | track_id | track_id, value | Playing slot index (-1 arrangement, -2 clip stop fired). | +| /live/track/start_listen/right_split_stereo | track_id | track_id, value | Right split stereo panning parameter. | +| /live/track/start_listen/solo | track_id | track_id, value | Query track solo on/off | +| /live/track/start_listen/track_activator | track_id | track_id, value | Track activator (on/off). | +| /live/track/start_listen/volume | track_id | track_id, value | Query track volume | +| /live/track/stop_listen/arm | track_id | | Query whether track is armed | +| /live/track/stop_listen/back_to_arranger | track_id | | Single Track Back to Arrangement state (1=lit). Setting to 0 returns to arrangement playback. | +| /live/track/stop_listen/color | track_id | | Query track color | +| /live/track/stop_listen/color_index | track_id | | Query track color index | +| /live/track/stop_listen/crossfade_assign | track_id | | Crossfader assignment (A/B/None). | +| /live/track/stop_listen/current_monitoring_state | track_id | | Query current monitoring state (1=on, 0=off) | +| /live/track/stop_listen/fired_slot_index | track_id | | Blinking slot index (-1 none, -2 clip stop). | +| /live/track/stop_listen/has_audio_input | track_id | | Query has_audio_input | +| /live/track/stop_listen/has_audio_output | track_id | | Query has_audio_output | +| /live/track/stop_listen/has_midi_input | track_id | | Query has_midi_input | +| /live/track/stop_listen/has_midi_output | track_id | | Query has_midi_output | +| /live/track/stop_listen/implicit_arm | track_id | | Secondary arm state used by Push. | +| /live/track/stop_listen/input_meter_left | track_id | | Input meter left channel peak (0.0–1.0). | +| /live/track/stop_listen/input_meter_level | track_id | | Input meter hold peak (0.0–1.0). | +| /live/track/stop_listen/input_meter_right | track_id | | Input meter right channel peak (0.0–1.0). | +| /live/track/stop_listen/is_frozen | track_id | | 1 if track is currently frozen. | +| /live/track/stop_listen/is_showing_chains | track_id | | Whether Instrument Rack chains are shown in Session View. | +| /live/track/stop_listen/left_split_stereo | track_id | | Left split stereo panning parameter. | +| /live/track/stop_listen/mute | track_id | | Query track mute (1=on, 0=off) | +| /live/track/stop_listen/muted_via_solo | track_id | | 1 if track is muted because another track is soloed. | +| /live/track/stop_listen/name | track_id | | Query track name | +| /live/track/stop_listen/output_meter_left | track_id | | Query current output level, left channel | +| /live/track/stop_listen/output_meter_level | track_id | | Query current output level, both channels | +| /live/track/stop_listen/output_meter_right | track_id | | Query current output level, right channel | +| /live/track/stop_listen/panning | track_id | | Query track panning | +| /live/track/stop_listen/panning_mode | track_id | | Track panning mode. | +| /live/track/stop_listen/performance_impact | track_id | | Performance impact of this track. | +| /live/track/stop_listen/playing_slot_index | track_id | | Playing slot index (-1 arrangement, -2 clip stop fired). | +| /live/track/stop_listen/right_split_stereo | track_id | | Right split stereo panning parameter. | +| /live/track/stop_listen/solo | track_id | | Query track solo on/off | +| /live/track/stop_listen/track_activator | track_id | | Track activator (on/off). | +| /live/track/stop_listen/volume | track_id | | Query track volume | + +### Track View + +| Address | Query params | Response params | Description | +| :-------------------------------------- | :-------------- | :-------------- | :-------------------------------------- | +| /live/track/view/get/device_insert_mode | track_id | track_id, value | Whether device insert mode is enabled. | +| /live/track/view/get/is_collapsed | track_id | track_id, value | Whether track is collapsed in view. | +| /live/track/view/set/is_collapsed | track_id, value | | Set whether track is collapsed in view. | ### Track: Properties of multiple clips -| Address | Query params | Response params | Description | -|:---------------------------------------------|:-------------|:----------------------------|:-------------------------------------------------| -| /live/track/get/clips/name | track_id | track_id, [name, ....] | Query all clip names on track | -| /live/track/get/clips/length | track_id | track_id, [length, ...] | Query all clip lengths on track | -| /live/track/get/clips/color | track_id | track_id, [color, ...] | Query all clip colors on track | -| /live/track/get/arrangement_clips/name | track_id | track_id, [name, ....] | Query all arrangement view clip names on track | -| /live/track/get/arrangement_clips/length | track_id | track_id, [length, ...] | Query all arrangement view clip lengths on track | -| /live/track/get/arrangement_clips/start_time | track_id | track_id, [start_time, ...] | Query all arrangement view clip times on track | +| Address | Query params | Response params | Description | +| :------------------------------------------- | :----------- | :-------------------- | :----------------------------------------------- | +| /live/track/get/clips/name | track_id | track_id, [values...] | Query all clip names on track | +| /live/track/get/clips/length | track_id | track_id, [values...] | Query all clip lengths on track | +| /live/track/get/clips/color | track_id | track_id, [values...] | Query all clip colors on track | +| /live/track/get/arrangement_clips/name | track_id | track_id, [values...] | Query all arrangement view clip names on track | +| /live/track/get/arrangement_clips/length | track_id | track_id, [values...] | Query all arrangement view clip lengths on track | +| /live/track/get/arrangement_clips/start_time | track_id | track_id, [values...] | Query all arrangement view clip times on track | ### Track: Properties of devices -| Address | Query params | Response params | Description | -|:-----------------------------------|:-------------|:-----------------------|:-----------------------------------------| -| /live/track/get/num_devices | track_id | track_id, num_devices | Query the number of devices on the track | -| /live/track/get/devices/name | track_id | track_id, [name, ...] | Query all device names on track | -| /live/track/get/devices/type | track_id | track_id, [type, ...] | Query all devices types on track | -| /live/track/get/devices/class_name | track_id | track_id, [class, ...] | Query all device class names on track | +| Address | Query params | Response params | Description | +| :-------------------------------------- | :----------- | :-------------------- | :--------------------------------------- | +| /live/track/get/num_devices | track_id | track_id, [values...] | Query the number of devices on the track | +| /live/track/get/devices/name | track_id | track_id, [values...] | Query all device names on track | +| /live/track/get/devices/type | track_id | track_id, [values...] | Query all devices types on track | +| /live/track/get/devices/class_name | track_id | track_id, [values...] | Query all device class names on track | +| /live/track/get/devices/can_have_chains | track_id | track_id, [bool, ...] | Query whether devices can have chains | See [Device API](#device-api) for details on Device type/class_names.