From c12e024dc6057553ff4cfa157f8b5cd7aef55946 Mon Sep 17 00:00:00 2001 From: Dandiggas Date: Sat, 21 Mar 2026 11:58:08 +0000 Subject: [PATCH 1/2] feat: Add Browser API support Adds browser.py handler exposing Live's Browser API via OSC: New endpoints: - /live/browser/get/user_folders - List user folders in Places - /live/browser/get/samples - List samples root items - /live/browser/list [path] - List items at a browser path - /live/browser/load [path] - Load item to current selection - /live/browser/preview [path] - Preview audio file - /live/browser/stop_preview - Stop preview - /live/browser/load_to_slot [path, track, slot] - Load to specific Session clip slot - /live/browser/load_to_arrangement [path, track, beat] - Load to Arrangement view Also adds view control endpoints in application.py: - /live/application/get/focused_document_view - /live/application/focus_view [view_name] - /live/application/show_view [view_name] Closes #XX (Add browser API request) --- abletonosc/__init__.py | 1 + abletonosc/application.py | 24 +++ abletonosc/browser.py | 309 ++++++++++++++++++++++++++++++++++++++ manager.py | 2 + 4 files changed, 336 insertions(+) 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/application.py b/abletonosc/application.py index 6c058f6..7ef3b3b 100644 --- a/abletonosc/application.py +++ b/abletonosc/application.py @@ -18,3 +18,27 @@ def get_average_process_usage(_) -> Tuple: return application.average_process_usage, self.osc_server.add_handler("/live/application/get/average_process_usage", get_average_process_usage) self.osc_server.send("/live/application/get/average_process_usage") + + #-------------------------------------------------------------------------------- + # View controls + #-------------------------------------------------------------------------------- + def get_focused_document_view(_) -> Tuple: + application = Live.Application.get_application() + return (application.view.focused_document_view,) + self.osc_server.add_handler("/live/application/get/focused_document_view", get_focused_document_view) + + def focus_view(params) -> Tuple: + """Focus a view by name (e.g., 'Session', 'Arranger', 'Detail/Clip')""" + application = Live.Application.get_application() + view_name = params[0] if params else 'Session' + application.view.focus_view(view_name) + return ("success", view_name) + self.osc_server.add_handler("/live/application/focus_view", focus_view) + + def show_view(params) -> Tuple: + """Show a view by name""" + application = Live.Application.get_application() + view_name = params[0] if params else 'Session' + application.view.show_view(view_name) + return ("success", view_name) + self.osc_server.add_handler("/live/application/show_view", show_view) diff --git a/abletonosc/browser.py b/abletonosc/browser.py new file mode 100644 index 0000000..bc165ff --- /dev/null +++ b/abletonosc/browser.py @@ -0,0 +1,309 @@ +""" +Browser handler for AbletonOSC. +Exposes Live's Browser API for loading audio files into the session. +""" + +import Live +import os +from typing import Tuple, Optional, List +from .handler import AbletonOSCHandler + + +class BrowserHandler(AbletonOSCHandler): + def __init__(self, manager): + super().__init__(manager) + self.class_identifier = "browser" + + def init_api(self): + # Get user folders + self.osc_server.add_handler("/live/browser/get/user_folders", self._get_user_folders) + + # Get samples root + self.osc_server.add_handler("/live/browser/get/samples", self._get_samples) + + # List items in a browser item (by path) + self.osc_server.add_handler("/live/browser/list", self._list_items) + + # Load an item by navigating to it + self.osc_server.add_handler("/live/browser/load", self._load_item) + + # Preview an item + self.osc_server.add_handler("/live/browser/preview", self._preview_item) + + # Stop preview + self.osc_server.add_handler("/live/browser/stop_preview", self._stop_preview) + + # Load file directly to selected track + self.osc_server.add_handler("/live/browser/load_file", self._load_file_to_track) + + # Load file to specific session clip slot + self.osc_server.add_handler("/live/browser/load_to_slot", self._load_to_session_slot) + + # Load file to arrangement view at specific position + self.osc_server.add_handler("/live/browser/load_to_arrangement", self._load_to_arrangement) + + def _get_browser(self): + """Get the Live browser instance.""" + return Live.Application.get_application().browser + + def _get_user_folders(self, params) -> Tuple: + """Get list of user folder names.""" + browser = self._get_browser() + folders = [] + for item in browser.user_folders: + folders.append(item.name) + return tuple(folders) + + def _get_samples(self, params) -> Tuple: + """Get samples browser item children.""" + browser = self._get_browser() + samples = browser.samples + items = [] + for child in samples.children: + items.append(child.name) + return tuple(items) + + def _find_item_by_path(self, path_parts: List[str], start_item=None): + """ + Navigate through browser hierarchy to find an item. + path_parts: list of folder/file names to navigate through + """ + browser = self._get_browser() + + if start_item is None: + # Start from user_folders or samples based on first path part + first = path_parts[0].lower() + if first == "samples": + current = browser.samples + path_parts = path_parts[1:] + elif first == "user library": + current = browser.user_library + path_parts = path_parts[1:] + else: + # Try to find in user_folders + current = None + for folder in browser.user_folders: + if folder.name.lower() == first: + current = folder + path_parts = path_parts[1:] + break + if current is None: + return None + else: + current = start_item + + # Navigate through the path + for part in path_parts: + found = False + for child in current.children: + if child.name.lower() == part.lower(): + current = child + found = True + break + if not found: + return None + + return current + + def _list_items(self, params) -> Tuple: + """ + List items at a given path. + params[0]: path (e.g., "Samples/Drums" or "User Library/My Folder") + """ + if not params: + return ("error", "No path provided") + + path = params[0] + path_parts = [p.strip() for p in path.split("/") if p.strip()] + + item = self._find_item_by_path(path_parts) + if item is None: + return ("error", f"Path not found: {path}") + + children = [] + for child in item.children: + children.append(child.name) + + return tuple(children) + + def _load_item(self, params) -> Tuple: + """ + Load an item by path. + params[0]: path to the item (e.g., "User Library/Stems/kick.wav") + """ + if not params: + return ("error", "No path provided") + + path = params[0] + path_parts = [p.strip() for p in path.split("/") if p.strip()] + + item = self._find_item_by_path(path_parts) + if item is None: + return ("error", f"Item not found: {path}") + + if not item.is_loadable: + return ("error", f"Item is not loadable: {path}") + + browser = self._get_browser() + browser.load_item(item) + + return ("success", f"Loaded: {item.name}") + + def _preview_item(self, params) -> Tuple: + """ + Preview an item by path. + params[0]: path to the item + """ + if not params: + return ("error", "No path provided") + + path = params[0] + path_parts = [p.strip() for p in path.split("/") if p.strip()] + + item = self._find_item_by_path(path_parts) + if item is None: + return ("error", f"Item not found: {path}") + + browser = self._get_browser() + browser.preview_item(item) + + return ("success", f"Previewing: {item.name}") + + def _stop_preview(self, params) -> Tuple: + """Stop any current preview.""" + browser = self._get_browser() + browser.stop_preview() + return ("success", "Preview stopped") + + def _load_file_to_track(self, params) -> Tuple: + """ + Load an audio file to the currently selected track. + This is a convenience method that: + 1. Finds the file in the browser + 2. Loads it to the selected track's first empty clip slot + + params[0]: Full file path or browser path + """ + if not params: + return ("error", "No file path provided") + + file_path = params[0] + + # If it's an absolute file path, we need to find it in the browser + # This requires the file to be in a location Live can see (user folders, samples, etc.) + if os.path.isabs(file_path): + # Extract filename + filename = os.path.basename(file_path) + # For now, return guidance - full implementation would need to search + return ("error", f"Absolute paths not yet supported. Add the folder to Live's browser first, then use: /live/browser/load 'FolderName/{filename}'") + + # Treat as browser path + path_parts = [p.strip() for p in file_path.split("/") if p.strip()] + + item = self._find_item_by_path(path_parts) + if item is None: + return ("error", f"Item not found: {file_path}") + + if not item.is_loadable: + return ("error", f"Item is not loadable: {file_path}") + + browser = self._get_browser() + browser.load_item(item) + + return ("success", f"Loaded to selected track: {item.name}") + + def _load_to_session_slot(self, params) -> Tuple: + """ + Load an audio file to a specific clip slot in Session view. + + params[0]: Browser path to the audio file + params[1]: Track index (0-based) + params[2]: Scene/slot index (0-based) + """ + if len(params) < 3: + return ("error", "Required: path, track_index, slot_index") + + file_path = params[0] + track_index = int(params[1]) + slot_index = int(params[2]) + + # Find the browser item + path_parts = [p.strip() for p in file_path.split("/") if p.strip()] + item = self._find_item_by_path(path_parts) + if item is None: + return ("error", f"Item not found: {file_path}") + + if not item.is_loadable: + return ("error", f"Item is not loadable: {file_path}") + + # Get the song and set up the target + song = self.song + app = Live.Application.get_application() + + # Ensure we're in Session view + app.view.show_view("Session") + app.view.focus_view("Session") + + # Select the track and scene to highlight the clip slot + track = song.tracks[track_index] + scene = song.scenes[slot_index] + + song.view.selected_track = track + song.view.selected_scene = scene + + # Set the highlighted clip slot - this is the key! + clip_slot = track.clip_slots[slot_index] + song.view.highlighted_clip_slot = clip_slot + + # Now load - it should go to the highlighted slot + browser = self._get_browser() + browser.load_item(item) + + return ("success", f"Loaded {item.name} to track {track_index}, slot {slot_index}") + + def _load_to_arrangement(self, params) -> Tuple: + """ + Load an audio file to the Arrangement view at a specific position. + + params[0]: Browser path to the audio file + params[1]: Track index (0-based) + params[2]: Time position in beats (optional, defaults to current song time) + """ + if len(params) < 2: + return ("error", "Required: path, track_index [, time_in_beats]") + + file_path = params[0] + track_index = int(params[1]) + time_position = float(params[2]) if len(params) > 2 else None + + # Find the browser item + path_parts = [p.strip() for p in file_path.split("/") if p.strip()] + item = self._find_item_by_path(path_parts) + if item is None: + return ("error", f"Item not found: {file_path}") + + if not item.is_loadable: + return ("error", f"Item is not loadable: {file_path}") + + # Get the song and set up the target + song = self.song + app = Live.Application.get_application() + + # Switch to Arrangement view + app.view.show_view("Arranger") + app.view.focus_view("Arranger") + + # Select the track + track = song.tracks[track_index] + song.view.selected_track = track + + # Set the song time position if specified + if time_position is not None: + song.current_song_time = time_position + + # Load - should go to arrangement at the current position + browser = self._get_browser() + browser.load_item(item) + + pos = time_position if time_position is not None else song.current_song_time + return ("success", f"Loaded {item.name} to track {track_index} at beat {pos}") 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() From f7c971817561cc5f992d52c3bc940200acaaa1fb Mon Sep 17 00:00:00 2001 From: Dandiggas Date: Sat, 21 Mar 2026 12:08:13 +0000 Subject: [PATCH 2/2] docs: Add endpoint reference and known limitations to browser.py --- abletonosc/browser.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/abletonosc/browser.py b/abletonosc/browser.py index bc165ff..e655c9e 100644 --- a/abletonosc/browser.py +++ b/abletonosc/browser.py @@ -1,6 +1,22 @@ """ Browser handler for AbletonOSC. Exposes Live's Browser API for loading audio files into the session. + +Endpoints: + /live/browser/get/user_folders - List user folders in Places + /live/browser/get/samples - List items in Samples root + /live/browser/list [path] - List items at a browser path + /live/browser/load [path] - Load item to current selection + /live/browser/preview [path] - Preview audio file + /live/browser/stop_preview - Stop preview + /live/browser/load_to_slot [path, track, slot] - Load to Session clip slot + /live/browser/load_to_arrangement [path, track, beat] - Load to Arrangement view + +Known Limitations: + - Session view loading (load_to_slot) is reliable via highlighted_clip_slot + - Arrangement view loading depends on UI state and may require additional + automation for consistent results. The Live API does not expose direct + arrangement clip insertion at a specific position. """ import Live