From fc69956698ab01d56725194b1dcdbf0f06109d97 Mon Sep 17 00:00:00 2001 From: Lucas Draney Date: Tue, 13 Jan 2026 09:46:56 -0700 Subject: [PATCH 1/5] fix: Live 12 compatibility - use full package path for pythonosc Changed imports in abletonosc/osc_server.py: - FROM: `from ..pythonosc` (relative - fails with "beyond top-level package") - TO: `from AbletonOSC.pythonosc` (full package path - works) Note: `from pythonosc` alone doesn't work because the module isn't on Python's path directly - it needs the full package prefix. Related to upstream issue #121 Co-Authored-By: Claude Opus 4.5 --- abletonosc/osc_server.py | 7 ++++--- tmpclaude-2f62-cwd | 1 + tmpclaude-fb7e-cwd | 1 + 3 files changed, 6 insertions(+), 3 deletions(-) create mode 100644 tmpclaude-2f62-cwd create mode 100644 tmpclaude-fb7e-cwd diff --git a/abletonosc/osc_server.py b/abletonosc/osc_server.py index 4101817..fadafa8 100644 --- a/abletonosc/osc_server.py +++ b/abletonosc/osc_server.py @@ -1,8 +1,9 @@ from typing import Tuple, Any, Callable from .constants import OSC_LISTEN_PORT, OSC_RESPONSE_PORT -from ..pythonosc.osc_message import OscMessage, ParseError -from ..pythonosc.osc_bundle import OscBundle -from ..pythonosc.osc_message_builder import OscMessageBuilder, BuildError +# Live 12 fix: use full package path (relative imports fail, bare module name not found) +from AbletonOSC.pythonosc.osc_message import OscMessage, ParseError +from AbletonOSC.pythonosc.osc_bundle import OscBundle +from AbletonOSC.pythonosc.osc_message_builder import OscMessageBuilder, BuildError import re import errno diff --git a/tmpclaude-2f62-cwd b/tmpclaude-2f62-cwd new file mode 100644 index 0000000..c03888a --- /dev/null +++ b/tmpclaude-2f62-cwd @@ -0,0 +1 @@ +/c/Users/drane/ableton-mcp/AbletonOSC diff --git a/tmpclaude-fb7e-cwd b/tmpclaude-fb7e-cwd new file mode 100644 index 0000000..c03888a --- /dev/null +++ b/tmpclaude-fb7e-cwd @@ -0,0 +1 @@ +/c/Users/drane/ableton-mcp/AbletonOSC From 533a1258901addb7ecaafbb1fa73b1f00c8da3ee Mon Sep 17 00:00:00 2001 From: ldraney Date: Sun, 18 Jan 2026 16:30:11 -0700 Subject: [PATCH 2/5] Add /live/track/insert_device endpoint for loading devices via OSC Adds the ability to load devices onto tracks by name using the Live browser API. Usage: /live/track/insert_device [device_index] Example: /live/track/insert_device 0 "Wavetable" -1 The handler searches through browser.instruments, browser.audio_effects, browser.midi_effects, browser.drums, and browser.sounds for a matching device name and loads it onto the specified track. Returns: - (device_index,) on success - the index of the newly added device - (-1,) if device not found This enables programmatic device insertion, which was previously not possible via OSC. Useful for automated music production workflows and AI-assisted composition tools. Co-Authored-By: Claude Opus 4.5 --- abletonosc/track.py | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/abletonosc/track.py b/abletonosc/track.py index 5e21353..4d82bf0 100644 --- a/abletonosc/track.py +++ b/abletonosc/track.py @@ -1,4 +1,5 @@ from typing import Tuple, Any, Callable, Optional +import Live from .handler import AbletonOSCHandler @@ -108,6 +109,48 @@ def track_delete_clip(track, params: Tuple[Any]): self.osc_server.add_handler("/live/track/delete_clip", create_track_callback(track_delete_clip)) + #-------------------------------------------------------------------------------- + # Insert device by URI/name + # Usage: /live/track/insert_device [device_index] + # Example: /live/track/insert_device 0 "Reverb" -1 + #-------------------------------------------------------------------------------- + def track_insert_device(track, params: Tuple[Any]): + device_uri = str(params[0]) + device_index = int(params[1]) if len(params) > 1 else -1 + self.logger.info("Inserting device '%s' at index %d" % (device_uri, device_index)) + + # Use the browser to load the device + application = Live.Application.get_application() + browser = application.browser + + # Search for the device in available items + # Try instruments first, then audio effects, then midi effects + search_locations = [ + browser.instruments, + browser.audio_effects, + browser.midi_effects, + browser.drums, + browser.sounds, + ] + + device_item = None + for location in search_locations: + for item in location.children: + if item.name == device_uri or device_uri in item.name: + device_item = item + break + if device_item: + break + + if device_item: + browser.load_item(device_item) + return (len(track.devices) - 1,) + else: + self.logger.warning("Device not found: %s" % device_uri) + return (-1,) + + self.osc_server.add_handler("/live/track/insert_device", create_track_callback(track_insert_device)) + 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) From 716d8810dcbf1e9ea80a0464019d9ce595e3ce1d Mon Sep 17 00:00:00 2001 From: ldraney Date: Sun, 18 Jan 2026 17:27:15 -0700 Subject: [PATCH 3/5] Add logs/ to gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index f66c226..80e63ed 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ *.swp __pycache__ .DS_Store +logs/ From 2e6201ea1269e23e6b283d91d35bc8500376df97 Mon Sep 17 00:00:00 2001 From: ldraney Date: Sun, 18 Jan 2026 19:35:51 -0700 Subject: [PATCH 4/5] Add is_recording to clip_slot properties Exposes the is_recording property for ClipSlot, which was missing from the read-only properties list. This aligns with the Clip handler which already exposes is_recording. Co-Authored-By: Claude Opus 4.5 --- abletonosc/clip_slot.py | 1 + 1 file changed, 1 insertion(+) diff --git a/abletonosc/clip_slot.py b/abletonosc/clip_slot.py index bfda31b..e90a91d 100644 --- a/abletonosc/clip_slot.py +++ b/abletonosc/clip_slot.py @@ -35,6 +35,7 @@ def clip_slot_callback(params: Tuple[Any]): "controls_other_clips", "is_group_slot", "is_playing", + "is_recording", "is_triggered", "playing_status", "will_record_on_start", From 083629b95821d7f82afcf0755a902b9c807fa71e Mon Sep 17 00:00:00 2001 From: ldraney Date: Tue, 20 Jan 2026 14:45:18 -0700 Subject: [PATCH 5/5] Fix Live 12 browser.packs API: use iter_children In Live 12, browser.packs is a BrowserItem, not an iterable. Changed all 'for pack in browser.packs:' to 'for pack in browser.packs.iter_children:' to fix TypeError: 'BrowserItem' object is not iterable. Co-Authored-By: Claude Opus 4.5 --- abletonosc/__init__.py | 1 + abletonosc/browser.py | 359 +++++++++++++++++++++++++++++++++++++++++ abletonosc/track.py | 81 +++++++++- manager.py | 2 + 4 files changed, 439 insertions(+), 4 deletions(-) create mode 100644 abletonosc/browser.py diff --git a/abletonosc/__init__.py b/abletonosc/__init__.py index 53ba155..80de851 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 .browser import BrowserHandler from .constants import OSC_LISTEN_PORT, OSC_RESPONSE_PORT diff --git a/abletonosc/browser.py b/abletonosc/browser.py new file mode 100644 index 0000000..a9f4465 --- /dev/null +++ b/abletonosc/browser.py @@ -0,0 +1,359 @@ +"""Browser operations for AbletonOSC. + +Provides access to Ableton's browser for exploring packs and loading devices. +Enables recursive searching through pack contents to find nested presets. +""" + +from typing import Tuple, Any, List +import Live +from .handler import AbletonOSCHandler + + +class BrowserHandler(AbletonOSCHandler): + def __init__(self, manager): + super().__init__(manager) + self.class_identifier = "browser" + + def init_api(self): + application = Live.Application.get_application() + browser = application.browser + + # ============================================================================= + # List Packs + # ============================================================================= + + def browser_list_packs(_): + """List all installed pack names. + + Returns tuple of pack names. + """ + try: + pack_names = [] + for pack in browser.packs.iter_children: + pack_names.append(pack.name) + self.logger.info("Found %d packs" % len(pack_names)) + return tuple(pack_names) + except Exception as e: + self.logger.error("Error listing packs: %s" % str(e)) + return () + + self.osc_server.add_handler("/live/browser/list_packs", browser_list_packs) + + # ============================================================================= + # List Pack Contents (Recursive) + # ============================================================================= + + def browser_list_pack_contents(params: Tuple[Any]): + """List all loadable items in a pack. + + Args: + params[0]: Pack name to search + params[1]: (optional) Max depth for recursion, default 10 + + Returns tuple of loadable item names with their full paths. + """ + if len(params) < 1: + self.logger.warning("list_pack_contents requires pack name") + return () + + pack_name = str(params[0]) + max_depth = int(params[1]) if len(params) > 1 else 10 + + # Find the pack + target_pack = None + for pack in browser.packs.iter_children: + if pack.name == pack_name or pack_name.lower() in pack.name.lower(): + target_pack = pack + break + + if not target_pack: + self.logger.warning("Pack not found: %s" % pack_name) + return () + + results = [] + self._collect_loadable_items(target_pack, "", results, max_depth) + self.logger.info("Found %d loadable items in pack '%s'" % (len(results), pack_name)) + return tuple(results) + + def _collect_loadable_items(item, path, results, depth): + """Recursively collect loadable items from a browser item.""" + if depth <= 0: + return + + current_path = path + "/" + item.name if path else item.name + + try: + for child in item.iter_children: + if child.is_loadable: + results.append(current_path + "/" + child.name) + if child.is_folder: + _collect_loadable_items(child, current_path, results, depth - 1) + except Exception as e: + self.logger.debug("Error iterating children of %s: %s" % (current_path, str(e))) + + self._collect_loadable_items = _collect_loadable_items + self.osc_server.add_handler("/live/browser/list_pack_contents", browser_list_pack_contents) + + # ============================================================================= + # Search Browser (All Packs) + # ============================================================================= + + def browser_search(params: Tuple[Any]): + """Search all packs for items matching a query. + + Args: + params[0]: Search query string + params[1]: (optional) Max results, default 50 + params[2]: (optional) Max depth for recursion, default 10 + + Returns tuple of (name, pack_name) pairs for matching items. + """ + if len(params) < 1: + self.logger.warning("search requires query string") + return () + + query = str(params[0]).lower() + max_results = int(params[1]) if len(params) > 1 else 50 + max_depth = int(params[2]) if len(params) > 2 else 10 + + results = [] + + # Search through all packs + for pack in browser.packs.iter_children: + if len(results) >= max_results: + break + self._search_item(pack, query, results, max_results, max_depth, pack.name) + + # Flatten results to tuple of strings: "item_name|pack_name|path" + output = [] + for item_name, pack_name, path in results: + output.append("%s|%s|%s" % (item_name, pack_name, path)) + + self.logger.info("Found %d items matching '%s'" % (len(output), query)) + return tuple(output) + + def _search_item(item, query, results, max_results, depth, pack_name, path=""): + """Recursively search browser items for matching names.""" + if depth <= 0 or len(results) >= max_results: + return + + current_path = path + "/" + item.name if path else item.name + + try: + for child in item.iter_children: + if len(results) >= max_results: + break + + if child.is_loadable and query in child.name.lower(): + results.append((child.name, pack_name, current_path + "/" + child.name)) + + if child.is_folder: + _search_item(child, query, results, max_results, depth - 1, pack_name, current_path) + except Exception as e: + self.logger.debug("Error searching %s: %s" % (current_path, str(e))) + + self._search_item = _search_item + self.osc_server.add_handler("/live/browser/search", browser_search) + + # ============================================================================= + # Load Item by Path + # ============================================================================= + + def browser_load_item(params: Tuple[Any]): + """Load a browser item by its full path. + + The path should be in format: "Pack Name/Folder/Subfolder/Item Name" + + Args: + params[0]: Full path to the item + + Returns (1,) on success, (-1,) on failure. + """ + if len(params) < 1: + self.logger.warning("load_item requires item path") + return (-1,) + + full_path = str(params[0]) + path_parts = full_path.split("/") + + if len(path_parts) < 2: + self.logger.warning("Invalid path format: %s" % full_path) + return (-1,) + + pack_name = path_parts[0] + item_path = path_parts[1:] + + # Find the pack + target_pack = None + for pack in browser.packs.iter_children: + if pack.name == pack_name or pack_name.lower() in pack.name.lower(): + target_pack = pack + break + + if not target_pack: + self.logger.warning("Pack not found: %s" % pack_name) + return (-1,) + + # Navigate to the item + current_item = target_pack + for i, part in enumerate(item_path): + found = False + try: + for child in current_item.iter_children: + if child.name == part or part.lower() in child.name.lower(): + current_item = child + found = True + break + except Exception as e: + self.logger.warning("Error navigating path: %s" % str(e)) + return (-1,) + + if not found: + self.logger.warning("Path component not found: %s (in %s)" % (part, full_path)) + return (-1,) + + # Load the item + if current_item.is_loadable: + browser.load_item(current_item) + self.logger.info("Loaded item: %s" % full_path) + return (1,) + else: + self.logger.warning("Item is not loadable: %s" % full_path) + return (-1,) + + self.osc_server.add_handler("/live/browser/load_item", browser_load_item) + + # ============================================================================= + # Search and Load (Convenience) + # ============================================================================= + + def browser_search_and_load(params: Tuple[Any]): + """Search for an item and load the first match. + + Searches all packs recursively for an item matching the query + and loads the first match found. + + Args: + params[0]: Search query string + + Returns (item_name,) on success, ("",) on failure. + """ + if len(params) < 1: + self.logger.warning("search_and_load requires query string") + return ("",) + + query = str(params[0]).lower() + + # Search through all packs + for pack in browser.packs.iter_children: + result = self._find_and_load(pack, query, 10) + if result: + return (result,) + + # Also search standard locations + search_locations = [ + browser.instruments, + browser.audio_effects, + browser.midi_effects, + browser.drums, + browser.sounds, + ] + + for location in search_locations: + result = self._find_and_load(location, query, 10) + if result: + return (result,) + + self.logger.warning("No item found matching: %s" % query) + return ("",) + + def _find_and_load(item, query, depth): + """Recursively find and load first matching item.""" + if depth <= 0: + return None + + try: + for child in item.iter_children: + # Check if this item matches + if child.is_loadable and query in child.name.lower(): + browser.load_item(child) + self.logger.info("Found and loaded: %s" % child.name) + return child.name + + # Recurse into folders + if child.is_folder: + result = _find_and_load(child, query, depth - 1) + if result: + return result + except Exception as e: + self.logger.debug("Error searching: %s" % str(e)) + + return None + + self._find_and_load = _find_and_load + self.osc_server.add_handler("/live/browser/search_and_load", browser_search_and_load) + + # ============================================================================= + # Get Standard Browser Locations + # ============================================================================= + + def browser_list_instruments(_): + """List top-level items in the instruments browser.""" + try: + items = [] + for item in browser.instruments.iter_children: + items.append(item.name) + return tuple(items) + except Exception as e: + self.logger.error("Error listing instruments: %s" % str(e)) + return () + + def browser_list_audio_effects(_): + """List top-level items in the audio effects browser.""" + try: + items = [] + for item in browser.audio_effects.iter_children: + items.append(item.name) + return tuple(items) + except Exception as e: + self.logger.error("Error listing audio effects: %s" % str(e)) + return () + + def browser_list_midi_effects(_): + """List top-level items in the MIDI effects browser.""" + try: + items = [] + for item in browser.midi_effects.iter_children: + items.append(item.name) + return tuple(items) + except Exception as e: + self.logger.error("Error listing MIDI effects: %s" % str(e)) + return () + + def browser_list_drums(_): + """List top-level items in the drums browser.""" + try: + items = [] + for item in browser.drums.iter_children: + items.append(item.name) + return tuple(items) + except Exception as e: + self.logger.error("Error listing drums: %s" % str(e)) + return () + + def browser_list_sounds(_): + """List top-level items in the sounds browser.""" + try: + items = [] + for item in browser.sounds.iter_children: + items.append(item.name) + return tuple(items) + except Exception as e: + self.logger.error("Error listing sounds: %s" % str(e)) + return () + + self.osc_server.add_handler("/live/browser/list_instruments", browser_list_instruments) + self.osc_server.add_handler("/live/browser/list_audio_effects", browser_list_audio_effects) + self.osc_server.add_handler("/live/browser/list_midi_effects", browser_list_midi_effects) + self.osc_server.add_handler("/live/browser/list_drums", browser_list_drums) + self.osc_server.add_handler("/live/browser/list_sounds", browser_list_sounds) diff --git a/abletonosc/track.py b/abletonosc/track.py index 4d82bf0..c4c4032 100644 --- a/abletonosc/track.py +++ b/abletonosc/track.py @@ -113,6 +113,9 @@ def track_delete_clip(track, params: Tuple[Any]): # Insert device by URI/name # Usage: /live/track/insert_device [device_index] # Example: /live/track/insert_device 0 "Reverb" -1 + # + # Enhanced to search recursively through all packs if not found in + # standard browser locations. #-------------------------------------------------------------------------------- def track_insert_device(track, params: Tuple[Any]): device_uri = str(params[0]) @@ -134,21 +137,91 @@ def track_insert_device(track, params: Tuple[Any]): ] device_item = None + query_lower = device_uri.lower() + + # First: Search top-level items in standard locations + # (matches both exact names and partial names) for location in search_locations: - for item in location.children: - if item.name == device_uri or device_uri in item.name: - device_item = item - break + try: + for item in location.children: + if item.name == device_uri or device_uri in item.name: + device_item = item + self.logger.info("Found item at top-level: %s" % item.name) + break + except Exception as e: + self.logger.debug("Error iterating %s: %s" % (location, str(e))) if device_item: break + # Second: If not found, search one level deep in standard locations + # (for presets inside folders like "Analog" folder) + if not device_item: + self.logger.info("Not found at top-level, searching one level deep...") + for location in search_locations: + try: + for folder in location.iter_children: + if folder.is_folder: + # If folder name matches, get first loadable child + if folder.name.lower() == query_lower or query_lower in folder.name.lower(): + for preset in folder.iter_children: + if preset.is_loadable: + device_item = preset + self.logger.info("Found preset in matching folder '%s': %s" % (folder.name, preset.name)) + break + if device_item: + break + # Also search child items by name + for preset in folder.iter_children: + if query_lower in preset.name.lower(): + device_item = preset + self.logger.info("Found matching item in folder '%s': %s" % (folder.name, preset.name)) + break + if device_item: + break + except Exception as e: + self.logger.debug("Error searching folder: %s" % str(e)) + if device_item: + break + if device_item: browser.load_item(device_item) + self.logger.info("Loaded device: %s" % device_item.name) return (len(track.devices) - 1,) else: self.logger.warning("Device not found: %s" % device_uri) return (-1,) + def _recursive_search(item, query, max_depth): + """Recursively search for a loadable item matching the query. + + Args: + item: Browser item to search + query: Lowercase search string + max_depth: Maximum recursion depth + + Returns: + Matching browser item or None + """ + if max_depth <= 0: + return None + + try: + for child in item.iter_children: + # Check if this item matches and is loadable + if child.is_loadable: + if child.name.lower() == query or query in child.name.lower(): + return child + + # Recurse into folders + if child.is_folder: + result = _recursive_search(child, query, max_depth - 1) + if result: + return result + except Exception as e: + pass # Silently continue on iteration errors + + return None + self.osc_server.add_handler("/live/track/insert_device", create_track_callback(track_insert_device)) def track_get_clip_names(track, _): diff --git a/manager.py b/manager.py index 94753c4..03999d3 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.BrowserHandler(self), ] def clear_api(self): @@ -130,6 +131,7 @@ def reload_imports(self): importlib.reload(abletonosc.song) importlib.reload(abletonosc.track) importlib.reload(abletonosc.view) + importlib.reload(abletonosc.browser) importlib.reload(abletonosc) except Exception as e: exc = traceback.format_exc()