Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
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 .browser import BrowserHandler
from .constants import OSC_LISTEN_PORT, OSC_RESPONSE_PORT
24 changes: 24 additions & 0 deletions abletonosc/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
325 changes: 325 additions & 0 deletions abletonosc/browser.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,325 @@
"""
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
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}")
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.BrowserHandler(self),
]

def clear_api(self):
Expand Down Expand Up @@ -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()
Expand Down