diff --git a/README.md b/README.md index 33c7a93..021fa27 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,26 @@ same IP as the originating message. When querying properties, OSC wildcard patte | /live/api/set/log_level | log_level | | Set the log level, which can be one of: `debug`, `info`, `warning`, `error`, `critical`. | | /live/api/show_message | message | | Show a message in Live's status bar | +### Introspection (Developer Tool) + +The introspection API allows developers and maintainers to discover available properties and methods on any Live object at runtime. This is primarily useful for development, debugging, and exploring the Live API. + +| Address | Query params | Response params | Description | +|:-------------------|:--------------------------------|:---------------------|:---------------------------------------------------------------| +| /live/introspect | object_type, object_ids... | object_ids..., "properties:", prop1, prop2, ..., "methods:", method1, method2, ... | Introspect a Live object and return its available properties and methods | + +**Supported object types:** +- `device` - Requires `track_id, device_id` (e.g., `/live/introspect device 0 0`) +- `clip` - Requires `track_id, clip_id` (e.g., `/live/introspect clip 0 1`) +- `track` - Requires `track_id` (e.g., `/live/introspect track 2`) +- `song` - No additional IDs required (e.g., `/live/introspect song`) + +**Developer tool:** A formatted introspection client is available in `tools/introspect.py`: +```bash +./tools/introspect.py device 0 0 # Basic introspection +./tools/introspect.py device 0 0 --highlight variation,macro # With highlighting +``` + ### Application status messages These messages are sent to the client automatically when the application state changes. @@ -479,6 +499,7 @@ Represents an instrument or effect. ### Device properties - Changes for any Parameter property can be listened for by calling `/live/device/start_listen/parameter/value ` +- Changes for Device Variations properties can be listened for by calling `/live/device/start_listen/variations/ ` (where property is `variation_count` or `selected_variation_index`) | Address | Query params | Response params | Description | |:-----------------------------------------|:-----------------------------------------|:-----------------------------------------|:----------------------------------------------------------------------------------------| @@ -495,6 +516,19 @@ Represents an instrument or effect. | /live/device/get/parameter/value | track_id, device_id, parameter_id | track_id, device_id, parameter_id, value | Get a device parameter value | | /live/device/get/parameter/value_string | track_id, device_id, parameter_id | track_id, device_id, parameter_id, value | Get the device parameter value as a readable string ex: 2500 Hz | | /live/device/set/parameter/value | track_id, device_id, parameter_id, value | | Set a device parameter value | +| /live/device/get/variations/variation_count | track_id, device_id | track_id, device_id, count | Get the number of variations (RackDevice only) | +| /live/device/get/variations/selected_variation_index | track_id, device_id | track_id, device_id, index | Get the selected variation index, -1 if none (RackDevice only) | +| /live/device/set/variations/selected_variation_index | track_id, device_id, index | | Select a variation by index (RackDevice only) | + +### Device methods + +| Address | Query params | Response params | Description | +|:------------------------------------------|:--------------------|:----------------|:---------------------------------------------------------------| +| /live/device/variations/recall_selected_variation | track_id, device_id | | Apply the selected variation's macro values (RackDevice only) | +| /live/device/variations/recall_last_used_variation | track_id, device_id | | Recall the last used variation (RackDevice only) | +| /live/device/variations/store_variation | track_id, device_id | | Store current macro values as a new variation (RackDevice only) | +| /live/device/variations/delete_selected_variation | track_id, device_id | | Delete the currently selected variation (RackDevice only) | +| /live/device/variations/randomize_macros | track_id, device_id | | Randomize all macro values in the rack (RackDevice only) | For devices: @@ -502,6 +536,7 @@ For devices: - `type` is 1 = audio_effect, 2 = instrument, 4 = midi_effect - `class_name` is the Live instrument/effect name, e.g. Operator, Reverb. For external plugins and racks, can be AuPluginDevice, PluginDevice, InstrumentGroupDevice... +- Device Variations (macro variations) are only available for RackDevice types (Instrument Rack, Audio Effect Rack, MIDI Effect Rack, Drum Rack) diff --git a/abletonosc/__init__.py b/abletonosc/__init__.py index 53ba155..72266ec 100644 --- a/abletonosc/__init__.py +++ b/abletonosc/__init__.py @@ -13,4 +13,5 @@ from .scene import SceneHandler from .view import ViewHandler from .midimap import MidiMapHandler +from .introspection import IntrospectionHandler from .constants import OSC_LISTEN_PORT, OSC_RESPONSE_PORT diff --git a/abletonosc/device.py b/abletonosc/device.py index 19c0681..6418d44 100644 --- a/abletonosc/device.py +++ b/abletonosc/device.py @@ -46,6 +46,60 @@ def device_callback(params: Tuple[Any]): self.osc_server.add_handler("/live/device/set/%s" % prop, create_device_callback(self._set_property, prop)) + #-------------------------------------------------------------------------------- + # Device Variations API (RackDevice only) + #-------------------------------------------------------------------------------- + + # Variations properties: /live/device/get/variations/{property} + def device_get_variations_num(device, params: Tuple[Any] = ()): + return (device.variation_count,) + + def device_get_variations_selected(device, params: Tuple[Any] = ()): + return (device.selected_variation_index,) + + def device_set_variations_selected(device, params: Tuple[Any] = ()): + device.selected_variation_index = int(params[0]) + + # Register variations property handlers + self.osc_server.add_handler("/live/device/get/variations/variation_count", + create_device_callback(device_get_variations_num)) + self.osc_server.add_handler("/live/device/get/variations/selected_variation_index", + create_device_callback(device_get_variations_selected)) + self.osc_server.add_handler("/live/device/set/variations/selected_variation_index", + create_device_callback(device_set_variations_selected)) + + # Variations listeners: /live/device/start_listen/variations/{property} + self.osc_server.add_handler("/live/device/start_listen/variations/variation_count", + create_device_callback(self._start_listen, "variation_count")) + self.osc_server.add_handler("/live/device/stop_listen/variations/variation_count", + create_device_callback(self._stop_listen, "variation_count")) + self.osc_server.add_handler("/live/device/start_listen/variations/selected_variation_index", + create_device_callback(self._start_listen, "selected_variation_index")) + self.osc_server.add_handler("/live/device/stop_listen/variations/selected_variation_index", + create_device_callback(self._stop_listen, "selected_variation_index")) + + # Variations methods: /live/device/variations/{method} + def device_variations_recall(device, params: Tuple[Any] = ()): + device.recall_selected_variation() + + def device_variations_recall_last(device, params: Tuple[Any] = ()): + device.recall_last_used_variation() + + def device_variations_delete(device, params: Tuple[Any] = ()): + device.delete_selected_variation() + + def device_variations_randomize(device, params: Tuple[Any] = ()): + device.randomize_macros() + + self.osc_server.add_handler("/live/device/variations/recall_selected_variation", + create_device_callback(device_variations_recall)) + self.osc_server.add_handler("/live/device/variations/recall_last_used_variation", + create_device_callback(device_variations_recall_last)) + self.osc_server.add_handler("/live/device/variations/delete_selected_variation", + create_device_callback(device_variations_delete)) + self.osc_server.add_handler("/live/device/variations/randomize_macros", + create_device_callback(device_variations_randomize)) + #-------------------------------------------------------------------------------- # Device: Get/set parameter lists #-------------------------------------------------------------------------------- @@ -140,3 +194,14 @@ def device_get_parameter_name(device, params: Tuple[Any] = ()): self.osc_server.add_handler("/live/device/get/parameter/name", create_device_callback(device_get_parameter_name)) self.osc_server.add_handler("/live/device/start_listen/parameter/value", create_device_callback(device_get_parameter_value_listener, include_ids = True)) self.osc_server.add_handler("/live/device/stop_listen/parameter/value", create_device_callback(device_get_parameter_remove_value_listener, include_ids = True)) + + #-------------------------------------------------------------------------------- + # Device: Store variation (separate handler for optional parameter) + #-------------------------------------------------------------------------------- + def device_variations_store(device, params: Tuple[Any] = ()): + """ + Store the current macro state as a variation. + """ + device.store_variation() + + self.osc_server.add_handler("/live/device/variations/store_variation", create_device_callback(device_variations_store)) diff --git a/abletonosc/introspection.py b/abletonosc/introspection.py index 74b500d..2738ae9 100644 --- a/abletonosc/introspection.py +++ b/abletonosc/introspection.py @@ -1,39 +1,156 @@ -import inspect -import logging -logger = logging.getLogger("abletonosc") +from typing import Tuple, Any +from .handler import AbletonOSCHandler -def describe_module(module): +class IntrospectionHandler(AbletonOSCHandler): """ - Describe the module object passed as argument - including its root classes and functions """ - - logger.info("Module: %s" % module) - for name in dir(module): - obj = getattr(module, name) - if inspect.ismodule(obj): - describe_module(obj) - elif inspect.isclass(obj): - logger.info("Class: %s" % name) - members = inspect.getmembers(obj) - - logger.info("Builtins") - for name, member in members: - if inspect.isbuiltin(member): - logger.info(" - %s" % (name)) - - logger.info("Functions") - for name, member in members: - if inspect.isfunction(member): - logger.info(" - %s" % (name)) - - logger.info("Properties") - for name, member in members: - if str(type(member)) == "": - logger.info(" - %s" % (name)) - - logger.info("----------") - - for name in dir(module): - obj = getattr(module, name) - if inspect.ismethod(obj) or inspect.isfunction(obj): - logger.info("Method", obj) \ No newline at end of file + Handler for introspecting Live objects via OSC. + Provides API discovery for any Live object type. + """ + + def __init__(self, manager): + super().__init__(manager) + self.class_identifier = "introspect" + + def init_api(self): + """ + Initialize the introspection API endpoint. + Endpoint: /live/introspect + """ + def introspect_callback(params: Tuple[Any]): + """ + Handle introspection requests for any Live object type. + + Args: + params: Tuple containing (object_type, object_ids...) + e.g., ("device", 2, 2) or ("clip", 0, 1) + """ + if len(params) < 1: + self.logger.error("Introspect: No object type specified") + return + + object_type = params[0] + object_ids = params[1:] + + # Get the appropriate Live object based on type + try: + if object_type == "device": + if len(object_ids) < 2: + self.logger.error("Introspect device: Missing track_id or device_id") + return + track_index, device_index = int(object_ids[0]), int(object_ids[1]) + + # Validate track exists + num_tracks = len(self.song.tracks) + if track_index >= num_tracks: + self.logger.error(f"Introspect device: Track {track_index} does not exist (available: 0-{num_tracks - 1})") + return + + # Validate device exists on track + num_devices = len(self.song.tracks[track_index].devices) + if device_index >= num_devices: + self.logger.error(f"Introspect device: Device {device_index} does not exist on track {track_index} (track has {num_devices} device(s))") + return + + obj = self.song.tracks[track_index].devices[device_index] + + elif object_type == "clip": + if len(object_ids) < 2: + self.logger.error("Introspect clip: Missing track_id or clip_id") + return + track_index, clip_index = int(object_ids[0]), int(object_ids[1]) + + # Validate track exists + num_tracks = len(self.song.tracks) + if track_index >= num_tracks: + self.logger.error(f"Introspect clip: Track {track_index} does not exist (available: 0-{num_tracks - 1})") + return + + # Validate clip slot exists and has a clip + track = self.song.tracks[track_index] + if clip_index >= len(track.clip_slots): + self.logger.error(f"Introspect clip: Clip slot {clip_index} does not exist on track {track_index}") + return + + if not track.clip_slots[clip_index].has_clip: + self.logger.error(f"Introspect clip: Clip slot {clip_index} on track {track_index} is empty") + return + + obj = track.clip_slots[clip_index].clip + + elif object_type == "track": + if len(object_ids) < 1: + self.logger.error("Introspect track: Missing track_id") + return + track_index = int(object_ids[0]) + + # Validate track exists + num_tracks = len(self.song.tracks) + if track_index >= num_tracks: + self.logger.error(f"Introspect track: Track {track_index} does not exist (available: 0-{num_tracks - 1})") + return + + obj = self.song.tracks[track_index] + + elif object_type == "song": + obj = self.song + + else: + self.logger.error(f"Introspect: Unknown object type '{object_type}'") + return + + except (IndexError, AttributeError) as e: + self.logger.error(f"Introspect: Unexpected error accessing {object_type} with IDs {object_ids}: {e}") + return + + # Perform introspection on the object + result = self._introspect_object(obj) + + # Return object_ids followed by introspection data + return (*object_ids, *result) + + self.osc_server.add_handler("/live/introspect", introspect_callback) + + def _introspect_object(self, obj) -> Tuple[str, ...]: + """ + Introspect a Live object and return its properties and methods. + + Args: + obj: The Live object to introspect + + Returns: + Tuple containing: ("properties:", prop1, prop2, ..., "methods:", method1, method2, ...) + """ + all_attrs = dir(obj) + + # Filter out private/magic methods and common inherited methods + filtered_attrs = [ + attr for attr in all_attrs + if not attr.startswith('_') and attr not in ['add_update_listener', 'remove_update_listener'] + ] + + properties = [] + methods = [] + + for attr in filtered_attrs: + try: + attr_obj = getattr(obj, attr) + # Check if it's callable (method) or a property + if callable(attr_obj): + methods.append(attr) + else: + # Try to get the value to see if it's a readable property + try: + # Limit string length to avoid OSC message size issues + # and improve readability in introspection output + value = str(attr_obj)[:50] + properties.append(f"{attr}={value}") + except: + properties.append(attr) + except: + pass + + # Return as tuples for OSC transmission (lowercase for consistency) + return ( + "properties:", *properties, + "methods:", *methods + ) \ No newline at end of file diff --git a/manager.py b/manager.py index 94753c4..652c587 100644 --- a/manager.py +++ b/manager.py @@ -100,6 +100,7 @@ def show_message_callback(params): abletonosc.ViewHandler(self), abletonosc.SceneHandler(self), abletonosc.MidiMapHandler(self), + abletonosc.IntrospectionHandler(self), ] def clear_api(self): @@ -125,6 +126,7 @@ def reload_imports(self): importlib.reload(abletonosc.clip_slot) importlib.reload(abletonosc.device) importlib.reload(abletonosc.handler) + importlib.reload(abletonosc.introspection) importlib.reload(abletonosc.osc_server) importlib.reload(abletonosc.scene) importlib.reload(abletonosc.song) diff --git a/run-console.py b/run-console.py index 3d59955..c9436d2 100755 --- a/run-console.py +++ b/run-console.py @@ -276,6 +276,19 @@ def main(args): "/live/device/get/parameter/value", "/live/device/get/parameter/value_string", "/live/device/set/parameter/value", + "/live/introspect", + "/live/device/get/variations/variation_count", + "/live/device/get/variations/selected_variation_index", + "/live/device/set/variations/selected_variation_index", + "/live/device/start_listen/variations/variation_count", + "/live/device/stop_listen/variations/variation_count", + "/live/device/start_listen/variations/selected_variation_index", + "/live/device/stop_listen/variations/selected_variation_index", + "/live/device/variations/recall_selected_variation", + "/live/device/variations/recall_last_used_variation", + "/live/device/variations/store_variation", + "/live/device/variations/delete_selected_variation", + "/live/device/variations/randomize_macros", # Add more addresses as needed ] diff --git a/tests/test_device.py b/tests/test_device.py new file mode 100644 index 0000000..2717535 --- /dev/null +++ b/tests/test_device.py @@ -0,0 +1,250 @@ +from . import client, wait_one_tick, TICK_DURATION +import pytest + +#-------------------------------------------------------------------------------- +# Device Variations tests +# +# To test variations: +# 1. Create an Instrument Rack or Effect Rack on track 0 (first track) +# 2. The tests will auto-create variations if the rack has none +# 3. Run: pytest tests/test_device.py +# +# Note: These tests will be skipped if the device doesn't support variations +# (e.g., not a RackDevice) +#-------------------------------------------------------------------------------- + +# Test configuration: Adjust these if your test rack is on a different track/device +RACK_TRACK_ID = 0 +RACK_DEVICE_ID = 0 + +def _device_has_variations(client, track_id, device_id): + """ + Check if a device supports variations by querying variations/variation_count. + Returns True if the device is a RackDevice with variation support. + """ + try: + result = client.query("/live/device/get/variations/variation_count", (track_id, device_id)) + # If we get a valid response with a variation count, the device supports variations + return result and len(result) >= 3 + except: + return False + +@pytest.fixture(scope="module", autouse=True) +def _check_rack_device(client): + """ + Check if a rack device exists on the test track. + If found but has no variations, create some automatically for testing. + Cleanup: Delete auto-created variations after tests complete. + Skip all tests if no RackDevice found. + """ + created_variations = False + + try: + # Check if device exists and get variation count + result = client.query("/live/device/get/variations/variation_count", (RACK_TRACK_ID, RACK_DEVICE_ID)) + if not result or len(result) < 3: + pytest.skip( + f"No RackDevice found on track {RACK_TRACK_ID}, device {RACK_DEVICE_ID}. " + "Please add an Instrument Rack or Audio Effect Rack to run these tests." + ) + + variation_count = result[2] + + # If RackDevice exists but has no variations, create some + if variation_count == 0: + print(f"\n๐Ÿ”ง Setting up test variations on track {RACK_TRACK_ID}, device {RACK_DEVICE_ID}...") + + # Store current state as variation 1 + client.send_message("/live/device/variations/store_variation", (RACK_TRACK_ID, RACK_DEVICE_ID)) + wait_one_tick() + + # Randomize macros to create different state + client.send_message("/live/device/variations/randomize_macros", (RACK_TRACK_ID, RACK_DEVICE_ID)) + wait_one_tick() + + # Store randomized state as variation 2 + client.send_message("/live/device/variations/store_variation", (RACK_TRACK_ID, RACK_DEVICE_ID)) + wait_one_tick() + + created_variations = True + print(f"โœ… Created 2 variations for testing") + + except Exception as e: + pytest.skip( + f"Could not access device on track {RACK_TRACK_ID}, device {RACK_DEVICE_ID}: {e}" + ) + + # Yield to run tests + yield + + # Cleanup: Delete variations we created + if created_variations: + try: + print(f"\n๐Ÿงน Cleaning up test variations...") + # Get current count + result = client.query("/live/device/get/variations/variation_count", (RACK_TRACK_ID, RACK_DEVICE_ID)) + if result and len(result) >= 3: + count = result[2] + # Delete all variations (in reverse order to avoid index issues) + for i in range(count - 1, -1, -1): + client.send_message("/live/device/set/variations/selected_variation_index", (RACK_TRACK_ID, RACK_DEVICE_ID, i)) + wait_one_tick() + client.send_message("/live/device/variations/delete_selected_variation", (RACK_TRACK_ID, RACK_DEVICE_ID)) + wait_one_tick() + print(f"โœ… Cleanup complete") + except Exception as e: + print(f"โš ๏ธ Cleanup failed: {e}") + +#-------------------------------------------------------------------------------- +# Test Device Variations - Read-only properties +#-------------------------------------------------------------------------------- + +def test_device_variations_num(client): + """Test that we can read the number of variations.""" + result = client.query("/live/device/get/variations/variation_count", (RACK_TRACK_ID, RACK_DEVICE_ID)) + assert len(result) == 3 + assert result[0] == RACK_TRACK_ID + assert result[1] == RACK_DEVICE_ID + assert isinstance(result[2], int) + assert result[2] >= 0 # Should have at least 0 variations + +def test_device_variations_num_listener(client): + """Test that we can listen to variation count changes.""" + # Start listening + client.send_message("/live/device/start_listen/variations/variation_count", (RACK_TRACK_ID, RACK_DEVICE_ID)) + wait_one_tick() + + # Stop listening + client.send_message("/live/device/stop_listen/variations/variation_count", (RACK_TRACK_ID, RACK_DEVICE_ID)) + wait_one_tick() + +#-------------------------------------------------------------------------------- +# Test Device Variations - Read/write properties +#-------------------------------------------------------------------------------- + +def test_device_variations_selected_get(client): + """Test that we can read the selected variation index.""" + result = client.query("/live/device/get/variations/selected_variation_index", (RACK_TRACK_ID, RACK_DEVICE_ID)) + assert len(result) == 3 + assert result[0] == RACK_TRACK_ID + assert result[1] == RACK_DEVICE_ID + assert isinstance(result[2], int) + # -1 means no variation selected, or 0+ for a selected variation + +def test_device_variations_selected_set(client): + """Test that we can set the selected variation index.""" + # Get the current variation and variation count + current_result = client.query("/live/device/get/variations/selected_variation_index", (RACK_TRACK_ID, RACK_DEVICE_ID)) + current_variation = current_result[2] + + count_result = client.query("/live/device/get/variations/variation_count", (RACK_TRACK_ID, RACK_DEVICE_ID)) + variation_count = count_result[2] + + if variation_count > 0: + # Try to select the first variation + client.send_message("/live/device/set/variations/selected_variation_index", (RACK_TRACK_ID, RACK_DEVICE_ID, 0)) + wait_one_tick() + + # Verify the change + result = client.query("/live/device/get/variations/selected_variation_index", (RACK_TRACK_ID, RACK_DEVICE_ID)) + assert result[2] == 0 + + # Restore original variation + client.send_message("/live/device/set/variations/selected_variation_index", (RACK_TRACK_ID, RACK_DEVICE_ID, current_variation)) + wait_one_tick() + else: + pytest.skip("No variations available to test variations/selected setter") + +def test_device_variations_selected_listener(client): + """Test that we can listen to selected variation changes.""" + # Start listening + client.send_message("/live/device/start_listen/variations/selected_variation_index", (RACK_TRACK_ID, RACK_DEVICE_ID)) + wait_one_tick() + + # Stop listening + client.send_message("/live/device/stop_listen/variations/selected_variation_index", (RACK_TRACK_ID, RACK_DEVICE_ID)) + wait_one_tick() + +#-------------------------------------------------------------------------------- +# Test Device Variations - Methods +#-------------------------------------------------------------------------------- + +def test_device_variations_recall(client): + """Test that we can recall the selected variation.""" + count_result = client.query("/live/device/get/variations/variation_count", (RACK_TRACK_ID, RACK_DEVICE_ID)) + variation_count = count_result[2] + + if variation_count > 0: + # Select a variation first + client.send_message("/live/device/set/variations/selected_variation_index", (RACK_TRACK_ID, RACK_DEVICE_ID, 0)) + wait_one_tick() + + # Recall it + client.send_message("/live/device/variations/recall_selected_variation", (RACK_TRACK_ID, RACK_DEVICE_ID)) + wait_one_tick() + # No assertion - just verify the command doesn't error + else: + pytest.skip("No variations available to test variations/recall") + +def test_device_variations_recall_last(client): + """Test that we can recall the last used variation.""" + client.send_message("/live/device/variations/recall_last_used_variation", (RACK_TRACK_ID, RACK_DEVICE_ID)) + wait_one_tick() + # No assertion - just verify the command doesn't error + +def test_device_variations_randomize(client): + """Test that we can randomize macros.""" + # Store current state by reading selected variation + current_result = client.query("/live/device/get/variations/selected_variation_index", (RACK_TRACK_ID, RACK_DEVICE_ID)) + current_variation = current_result[2] + + # Randomize macros + client.send_message("/live/device/variations/randomize_macros", (RACK_TRACK_ID, RACK_DEVICE_ID)) + wait_one_tick() + + # Restore to original variation if one was selected + if current_variation >= 0: + client.send_message("/live/device/variations/recall_selected_variation", (RACK_TRACK_ID, RACK_DEVICE_ID)) + wait_one_tick() + +#-------------------------------------------------------------------------------- +# Test Device Variations - Destructive methods +#-------------------------------------------------------------------------------- + +def test_device_variations_store(client): + """Test that we can store a new variation.""" + # Get current count + count_before = client.query("/live/device/get/variations/variation_count", (RACK_TRACK_ID, RACK_DEVICE_ID))[2] + + # Store a new variation + client.send_message("/live/device/variations/store_variation", (RACK_TRACK_ID, RACK_DEVICE_ID)) + wait_one_tick() + + # Verify count increased + count_after = client.query("/live/device/get/variations/variation_count", (RACK_TRACK_ID, RACK_DEVICE_ID))[2] + assert count_after == count_before + 1 + + # Clean up: delete the variation we just created + client.send_message("/live/device/set/variations/selected_variation_index", (RACK_TRACK_ID, RACK_DEVICE_ID, count_after - 1)) + wait_one_tick() + client.send_message("/live/device/variations/delete_selected_variation", (RACK_TRACK_ID, RACK_DEVICE_ID)) + wait_one_tick() + +def test_device_variations_delete(client): + """Test that we can delete a variation.""" + # First create a variation to delete + client.send_message("/live/device/variations/store_variation", (RACK_TRACK_ID, RACK_DEVICE_ID)) + wait_one_tick() + + # Get count and select the last variation + count_before = client.query("/live/device/get/variations/variation_count", (RACK_TRACK_ID, RACK_DEVICE_ID))[2] + client.send_message("/live/device/set/variations/selected_variation_index", (RACK_TRACK_ID, RACK_DEVICE_ID, count_before - 1)) + wait_one_tick() + + # Delete it + client.send_message("/live/device/variations/delete_selected_variation", (RACK_TRACK_ID, RACK_DEVICE_ID)) + wait_one_tick() + + # Verify count decreased + count_after = client.query("/live/device/get/variations/variation_count", (RACK_TRACK_ID, RACK_DEVICE_ID))[2] + assert count_after == count_before - 1 diff --git a/tests/test_track.py b/tests/test_track.py index c1e90cd..6299d5e 100644 --- a/tests/test_track.py +++ b/tests/test_track.py @@ -72,7 +72,7 @@ def test_track_clips(client): #-------------------------------------------------------------------------------- def test_track_devices(client): - track_id = 0 + track_id = 1 assert client.query("/live/track/get/num_devices", (track_id,)) == (track_id, 0,) #-------------------------------------------------------------------------------- diff --git a/tools/introspect.py b/tools/introspect.py new file mode 100755 index 0000000..ea89c2d --- /dev/null +++ b/tools/introspect.py @@ -0,0 +1,318 @@ +#!/usr/bin/env python3 +""" +Generic introspection tool for Ableton Live objects via AbletonOSC. + +This utility allows you to discover all available properties and methods on any +Live object that has an introspection endpoint implemented. + +Usage: + ./devel/introspect.py device + ./devel/introspect.py clip + ./devel/introspect.py track + ./devel/introspect.py song + +Examples: + # Introspect first device on track 0 + ./devel/introspect.py device 0 0 + + # Introspect first clip on track 2 + ./devel/introspect.py clip 2 0 + + # Introspect track 1 + ./devel/introspect.py track 1 + + # Introspect song object + ./devel/introspect.py song + +Requirements: + - Ableton Live must be running + - AbletonOSC must be loaded as a Control Surface + - The introspection endpoint must be implemented for the object type + +""" + +import sys +import argparse +import socket +from pathlib import Path + +# Add parent directory to path to import client +sys.path.insert(0, str(Path(__file__).parent.parent)) +from client.client import AbletonOSCClient + +# Introspection endpoint (unified for all object types) +INTROSPECTION_ENDPOINT = "/live/introspect" + +def find_free_port(start_port=11001, max_attempts=10): + """Find a free UDP port for the OSC client.""" + for port in range(start_port, start_port + max_attempts): + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + sock.bind(('0.0.0.0', port)) + sock.close() + return port + except OSError: + continue + return None + +def format_introspection_output(result, object_type, object_ids, highlight_keywords=None): + """Format and print the introspection results in a readable way. + + Args: + result: The introspection data received from the server + object_type: Type of object being introspected + object_ids: IDs used to identify the object + highlight_keywords: Optional list of keywords to highlight in the output + """ + if not result or len(result) < 3: + print(f"โŒ No introspection data received for {object_type} {object_ids}") + return + + # Parse the result + current_section = None + properties = [] + methods = [] + + for item in result[len(object_ids):]: # Skip object IDs + if item == "properties:": + current_section = "properties" + elif item == "methods:": + current_section = "methods" + elif current_section == "properties": + properties.append(item) + elif current_section == "methods": + methods.append(item) + + # Print header + print("=" * 80) + print(f"INTROSPECTION: {object_type.upper()} {' '.join(map(str, object_ids))}") + print("=" * 80) + print() + + # Print properties + print("๐Ÿ“‹ PROPERTIES:") + print("-" * 80) + if properties: + # Highlight if keywords provided + if highlight_keywords: + interesting_props = [p for p in properties if any(k in p.lower() for k in highlight_keywords)] + + if interesting_props: + print(f"\n๐ŸŽฏ HIGHLIGHTED ({', '.join(highlight_keywords)}):") + for prop in sorted(interesting_props): + print(f" โœจ {prop}") + + print(f"\n๐Ÿ“ ALL PROPERTIES ({len(properties)} total):") + for prop in sorted(properties): + print(f" โ€ข {prop}") + else: + print(" (No properties found)") + + print() + + # Print methods + print("๐Ÿ”ง METHODS:") + print("-" * 80) + if methods: + # Highlight if keywords provided + if highlight_keywords: + interesting_methods = [m for m in methods if any(k in m.lower() for k in highlight_keywords)] + + if interesting_methods: + print(f"\n๐ŸŽฏ HIGHLIGHTED ({', '.join(highlight_keywords)}):") + for method in sorted(interesting_methods): + print(f" โœจ {method}()") + + print(f"\n๐Ÿ“ ALL METHODS ({len(methods)} total):") + for method in sorted(methods): + print(f" โ€ข {method}()") + else: + print(" (No methods found)") + + print() + print("=" * 80) + +def introspect_object(object_type, object_ids, client_port=None, highlight_keywords=None): + """ + Introspect a Live object and print its properties and methods. + + Args: + object_type: Type of object (device, clip, track, song) + object_ids: List of IDs to identify the object (e.g., [track_id, device_id]) + client_port: Optional client port to use + highlight_keywords: Optional list of keywords to highlight in the output + """ + # Validate object type + valid_types = ["device", "clip", "track", "song"] + if object_type not in valid_types: + print(f"โŒ Error: Unknown object type '{object_type}'") + print() + print("Supported object types:") + for obj_type in valid_types: + print(f" โ€ข {obj_type}") + return False + + # Find a free port if not specified + if client_port is None: + client_port = find_free_port() + if client_port is None: + print("โŒ Unable to find a free UDP port.") + return False + + # Connect to AbletonOSC + try: + client = AbletonOSCClient(client_port=client_port) + except Exception as e: + print(f"โŒ Connection error: {e}") + print() + print("โš ๏ธ Make sure:") + print(" 1. Ableton Live is running") + print(" 2. AbletonOSC is loaded as a Control Surface") + print() + return False + + try: + # Validate that the requested object exists before introspecting + # Use 2 second timeout for all queries (OSC can be slow) + QUERY_TIMEOUT = 2.0 + + if object_type == "device": + track_id, device_id = object_ids[0], object_ids[1] + + # Check if track exists + num_tracks_result = client.query("/live/song/get/num_tracks", (), timeout=QUERY_TIMEOUT) + num_tracks = num_tracks_result[0] if num_tracks_result else 0 + if track_id >= num_tracks: + print(f"โŒ Error: Track {track_id} does not exist") + print(f" Available tracks: 0-{num_tracks - 1} ({num_tracks} total)") + return False + + # Check if device exists on this track + num_devices_result = client.query("/live/track/get/num_devices", (track_id,), timeout=QUERY_TIMEOUT) + num_devices = num_devices_result[1] if len(num_devices_result) > 1 else 0 + if device_id >= num_devices: + print(f"โŒ Error: Device {device_id} does not exist on track {track_id}") + print(f" Track {track_id} has {num_devices} device(s)") + if num_devices > 0: + print(f" Available devices: 0-{num_devices - 1}") + else: + print(f" ๐Ÿ’ก Tip: Add a device to track {track_id} first") + return False + + elif object_type == "clip": + track_id, clip_id = object_ids[0], object_ids[1] + + # Check if track exists + num_tracks_result = client.query("/live/song/get/num_tracks", (), timeout=QUERY_TIMEOUT) + num_tracks = num_tracks_result[0] if num_tracks_result else 0 + if track_id >= num_tracks: + print(f"โŒ Error: Track {track_id} does not exist") + print(f" Available tracks: 0-{num_tracks - 1} ({num_tracks} total)") + return False + + # Check if clip exists + has_clip_result = client.query("/live/clip_slot/get/has_clip", (track_id, clip_id), timeout=QUERY_TIMEOUT) + has_clip = has_clip_result[2] if len(has_clip_result) > 2 else False + if not has_clip: + print(f"โŒ Error: Clip slot {clip_id} on track {track_id} is empty") + print(f" ๐Ÿ’ก Tip: Add a clip to track {track_id}, slot {clip_id} first") + return False + + elif object_type == "track": + track_id = object_ids[0] + + # Check if track exists + num_tracks_result = client.query("/live/song/get/num_tracks", (), timeout=QUERY_TIMEOUT) + num_tracks = num_tracks_result[0] if num_tracks_result else 0 + if track_id >= num_tracks: + print(f"โŒ Error: Track {track_id} does not exist") + print(f" Available tracks: 0-{num_tracks - 1} ({num_tracks} total)") + return False + + # Query the introspection endpoint with object_type as first parameter + params = (object_type,) + tuple(object_ids) + result = client.query(INTROSPECTION_ENDPOINT, params, timeout=QUERY_TIMEOUT) + format_introspection_output(result, object_type, object_ids, highlight_keywords) + return True + except Exception as e: + print(f"โŒ Introspection failed: {e}") + import traceback + traceback.print_exc() + return False + finally: + client.stop() + +def main(): + parser = argparse.ArgumentParser( + description="Introspect Ableton Live objects via AbletonOSC", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + %(prog)s device 0 0 Introspect device 0 on track 0 + %(prog)s device 2 1 Introspect device 1 on track 2 + %(prog)s clip 0 0 Introspect clip in slot 0 on track 0 + %(prog)s track 1 Introspect track 1 + %(prog)s song Introspect the song object + +Note: Currently only 'device' introspection is implemented. + Other object types will be added as introspection endpoints are created. + """ + ) + + parser.add_argument( + "object_type", + choices=["device", "clip", "track", "song"], + help="Type of Live object to introspect" + ) + + parser.add_argument( + "object_ids", + nargs="*", + type=int, + help="Object IDs (e.g., track_id device_id for devices)" + ) + + parser.add_argument( + "--port", + type=int, + help="Client port to use (default: auto-detect free port)" + ) + + parser.add_argument( + "--highlight", + type=str, + help="Comma-separated keywords to highlight in the output (e.g., 'variation,macro,chain')" + ) + + args = parser.parse_args() + + # Validate object IDs based on object type + expected_ids = { + "device": 2, # track_id, device_id + "clip": 2, # track_id, clip_id + "track": 1, # track_id + "song": 0, # no IDs needed + } + + expected = expected_ids.get(args.object_type, 0) + if len(args.object_ids) != expected: + print(f"โŒ Error: '{args.object_type}' requires {expected} ID(s), got {len(args.object_ids)}") + if expected > 0: + id_names = { + "device": "track_id device_id", + "clip": "track_id clip_id", + "track": "track_id", + } + print(f" Usage: {sys.argv[0]} {args.object_type} {id_names.get(args.object_type)}") + sys.exit(1) + + # Parse highlight keywords if provided + highlight_keywords = None + if args.highlight: + highlight_keywords = [kw.strip().lower() for kw in args.highlight.split(',')] + + success = introspect_object(args.object_type, args.object_ids, args.port, highlight_keywords) + sys.exit(0 if success else 1) + +if __name__ == "__main__": + main()