Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
6ad3cfa
Add Device Variations API support for Live 12
elzinko Nov 18, 2025
53dec5a
Refine Device Variations API - focus on variation-specific properties
elzinko Nov 18, 2025
e720e96
Refactor: improve code organization and documentation
elzinko Nov 18, 2025
4fdec9d
Add comprehensive testing guide
elzinko Nov 18, 2025
266e71b
Move introspect.py to devel/ and configure gitignore
elzinko Nov 18, 2025
813a7d6
Add Device Variations API autocomplete and documentation
elzinko Nov 18, 2025
2cda28d
Reorganize Device Variations into Device API section
elzinko Nov 18, 2025
079deb5
Refactor: Standardize Device Variations API nomenclature
elzinko Nov 19, 2025
5851238
Address maintainer review feedback
elzinko Nov 19, 2025
bfa2f28
Remove development/documentation files per maintainer feedback
elzinko Nov 19, 2025
2d44b83
Remove all devel/ files except introspect.py
elzinko Nov 19, 2025
c574465
fix(introspect): fix parsing and make highlighting generic
elzinko Nov 20, 2025
b9c5c80
refactor(introspect): move to generic /live/introspect endpoint
elzinko Nov 21, 2025
9cc8e2c
refactor: align Device Variations API names with Live API naming conv…
elzinko Nov 22, 2025
a886cce
Restore methods/properties_rw structure to match master
elzinko Nov 22, 2025
47af94a
fix(introspect): increase query timeout for large responses
elzinko Nov 22, 2025
945afae
feat(introspect): add validation with helpful error messages
elzinko Nov 22, 2025
cbfd106
feat(tests): auto-create variations for Device Variations tests
elzinko Nov 23, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 <track_index> <device index> <parameter_index>`
- Changes for Device Variations properties can be listened for by calling `/live/device/start_listen/variations/<property> <track_index> <device_index>` (where property is `variation_count` or `selected_variation_index`)

| Address | Query params | Response params | Description |
|:-----------------------------------------|:-----------------------------------------|:-----------------------------------------|:----------------------------------------------------------------------------------------|
Expand All @@ -495,13 +516,27 @@ 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:

- `name` is the human-readable name
- `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)

</details>

Expand Down
1 change: 1 addition & 0 deletions abletonosc/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
65 changes: 65 additions & 0 deletions abletonosc/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
#--------------------------------------------------------------------------------
Expand Down Expand Up @@ -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))
191 changes: 154 additions & 37 deletions abletonosc/introspection.py
Original file line number Diff line number Diff line change
@@ -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)) == "<type 'property'>":
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)
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 <object_type> <object_ids...>
"""
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
)
2 changes: 2 additions & 0 deletions manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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)
Expand Down
Loading