# Directory Structure ``` ├── .gitignore ├── .python-version ├── AbletonMCP_Remote_Script │ └── __init__.py ├── Dockerfile ├── LICENSE ├── MCP_Server │ ├── __init__.py │ └── server.py ├── pyproject.toml ├── README.md ├── smithery.yaml └── uv.lock ``` # Files -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- ``` 1 | 3.13 2 | ``` -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` 1 | # Python-generated files 2 | __pycache__/ 3 | *.py[oc] 4 | build/ 5 | dist/ 6 | wheels/ 7 | *.egg-info 8 | 9 | # Virtual environments 10 | .venv 11 | .DS_Store 12 | ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown 1 | # AbletonMCP - Ableton Live Model Context Protocol Integration 2 | [](https://smithery.ai/server/@ahujasid/ableton-mcp) 3 | 4 | AbletonMCP connects Ableton Live to Claude AI through the Model Context Protocol (MCP), allowing Claude to directly interact with and control Ableton Live. This integration enables prompt-assisted music production, track creation, and Live session manipulation. 5 | 6 | ### Join the Community 7 | 8 | Give feedback, get inspired, and build on top of the MCP: [Discord](https://discord.gg/3ZrMyGKnaU). Made by [Siddharth](https://x.com/sidahuj) 9 | 10 | ## Features 11 | 12 | - **Two-way communication**: Connect Claude AI to Ableton Live through a socket-based server 13 | - **Track manipulation**: Create, modify, and manipulate MIDI and audio tracks 14 | - **Instrument and effect selection**: Claude can access and load the right instruments, effects and sounds from Ableton's library 15 | - **Clip creation**: Create and edit MIDI clips with notes 16 | - **Session control**: Start and stop playback, fire clips, and control transport 17 | 18 | ## Components 19 | 20 | The system consists of two main components: 21 | 22 | 1. **Ableton Remote Script** (`Ableton_Remote_Script/__init__.py`): A MIDI Remote Script for Ableton Live that creates a socket server to receive and execute commands 23 | 2. **MCP Server** (`server.py`): A Python server that implements the Model Context Protocol and connects to the Ableton Remote Script 24 | 25 | ## Installation 26 | 27 | ### Installing via Smithery 28 | 29 | To install Ableton Live Integration for Claude Desktop automatically via [Smithery](https://smithery.ai/server/@ahujasid/ableton-mcp): 30 | 31 | ```bash 32 | npx -y @smithery/cli install @ahujasid/ableton-mcp --client claude 33 | ``` 34 | 35 | ### Prerequisites 36 | 37 | - Ableton Live 10 or newer 38 | - Python 3.8 or newer 39 | - [uv package manager](https://astral.sh/uv) 40 | 41 | If you're on Mac, please install uv as: 42 | ``` 43 | brew install uv 44 | ``` 45 | 46 | Otherwise, install from [uv's official website][https://docs.astral.sh/uv/getting-started/installation/] 47 | 48 | ⚠️ Do not proceed before installing UV 49 | 50 | ### Claude for Desktop Integration 51 | 52 | [Follow along with the setup instructions video](https://youtu.be/iJWJqyVuPS8) 53 | 54 | 1. Go to Claude > Settings > Developer > Edit Config > claude_desktop_config.json to include the following: 55 | 56 | ```json 57 | { 58 | "mcpServers": { 59 | "AbletonMCP": { 60 | "command": "uvx", 61 | "args": [ 62 | "ableton-mcp" 63 | ] 64 | } 65 | } 66 | } 67 | ``` 68 | 69 | ### Cursor Integration 70 | 71 | Run ableton-mcp without installing it permanently through uvx. Go to Cursor Settings > MCP and paste this as a command: 72 | 73 | ``` 74 | uvx ableton-mcp 75 | ``` 76 | 77 | ⚠️ Only run one instance of the MCP server (either on Cursor or Claude Desktop), not both 78 | 79 | ### Installing the Ableton Remote Script 80 | 81 | [Follow along with the setup instructions video](https://youtu.be/iJWJqyVuPS8) 82 | 83 | 1. Download the `AbletonMCP_Remote_Script/__init__.py` file from this repo 84 | 85 | 2. Copy the folder to Ableton's MIDI Remote Scripts directory. Different OS and versions have different locations. **One of these should work, you might have to look**: 86 | 87 | **For macOS:** 88 | - Method 1: Go to Applications > Right-click on Ableton Live app → Show Package Contents → Navigate to: 89 | `Contents/App-Resources/MIDI Remote Scripts/` 90 | - Method 2: If it's not there in the first method, use the direct path (replace XX with your version number): 91 | `/Users/[Username]/Library/Preferences/Ableton/Live XX/User Remote Scripts` 92 | 93 | **For Windows:** 94 | - Method 1: 95 | C:\Users\[Username]\AppData\Roaming\Ableton\Live x.x.x\Preferences\User Remote Scripts 96 | - Method 2: 97 | `C:\ProgramData\Ableton\Live XX\Resources\MIDI Remote Scripts\` 98 | - Method 3: 99 | `C:\Program Files\Ableton\Live XX\Resources\MIDI Remote Scripts\` 100 | *Note: Replace XX with your Ableton version number (e.g., 10, 11, 12)* 101 | 102 | 4. Create a folder called 'AbletonMCP' in the Remote Scripts directory and paste the downloaded '\_\_init\_\_.py' file 103 | 104 | 3. Launch Ableton Live 105 | 106 | 4. Go to Settings/Preferences → Link, Tempo & MIDI 107 | 108 | 5. In the Control Surface dropdown, select "AbletonMCP" 109 | 110 | 6. Set Input and Output to "None" 111 | 112 | ## Usage 113 | 114 | ### Starting the Connection 115 | 116 | 1. Ensure the Ableton Remote Script is loaded in Ableton Live 117 | 2. Make sure the MCP server is configured in Claude Desktop or Cursor 118 | 3. The connection should be established automatically when you interact with Claude 119 | 120 | ### Using with Claude 121 | 122 | Once the config file has been set on Claude, and the remote script is running in Ableton, you will see a hammer icon with tools for the Ableton MCP. 123 | 124 | ## Capabilities 125 | 126 | - Get session and track information 127 | - Create and modify MIDI and audio tracks 128 | - Create, edit, and trigger clips 129 | - Control playback 130 | - Load instruments and effects from Ableton's browser 131 | - Add notes to MIDI clips 132 | - Change tempo and other session parameters 133 | 134 | ## Example Commands 135 | 136 | Here are some examples of what you can ask Claude to do: 137 | 138 | - "Create an 80s synthwave track" [Demo](https://youtu.be/VH9g66e42XA) 139 | - "Create a Metro Boomin style hip-hop beat" 140 | - "Create a new MIDI track with a synth bass instrument" 141 | - "Add reverb to my drums" 142 | - "Create a 4-bar MIDI clip with a simple melody" 143 | - "Get information about the current Ableton session" 144 | - "Load a 808 drum rack into the selected track" 145 | - "Add a jazz chord progression to the clip in track 1" 146 | - "Set the tempo to 120 BPM" 147 | - "Play the clip in track 2" 148 | 149 | 150 | ## Troubleshooting 151 | 152 | - **Connection issues**: Make sure the Ableton Remote Script is loaded, and the MCP server is configured on Claude 153 | - **Timeout errors**: Try simplifying your requests or breaking them into smaller steps 154 | - **Have you tried turning it off and on again?**: If you're still having connection errors, try restarting both Claude and Ableton Live 155 | 156 | ## Technical Details 157 | 158 | ### Communication Protocol 159 | 160 | The system uses a simple JSON-based protocol over TCP sockets: 161 | 162 | - Commands are sent as JSON objects with a `type` and optional `params` 163 | - Responses are JSON objects with a `status` and `result` or `message` 164 | 165 | ### Limitations & Security Considerations 166 | 167 | - Creating complex musical arrangements might need to be broken down into smaller steps 168 | - The tool is designed to work with Ableton's default devices and browser items 169 | - Always save your work before extensive experimentation 170 | 171 | ## Contributing 172 | 173 | Contributions are welcome! Please feel free to submit a Pull Request. 174 | 175 | ## Disclaimer 176 | 177 | This is a third-party integration and not made by Ableton. 178 | ``` -------------------------------------------------------------------------------- /MCP_Server/__init__.py: -------------------------------------------------------------------------------- ```python 1 | """Ableton Live integration through the Model Context Protocol.""" 2 | 3 | __version__ = "0.1.0" 4 | 5 | # Expose key classes and functions for easier imports 6 | from .server import AbletonConnection, get_ableton_connection ``` -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- ```dockerfile 1 | # Generated by https://smithery.ai. See: https://smithery.ai/docs/config#dockerfile 2 | FROM python:3.10-alpine 3 | 4 | # Install build dependencies 5 | RUN apk add --no-cache gcc musl-dev libffi-dev 6 | 7 | WORKDIR /app 8 | 9 | # Copy project files 10 | COPY . /app 11 | 12 | # Install Python dependencies 13 | RUN pip install --no-cache-dir . 14 | 15 | # Expose port if server uses it, although MCP might use stdio 16 | 17 | # Command to run the MCP server 18 | CMD ["python", "-m", "MCP_Server.server"] 19 | ``` -------------------------------------------------------------------------------- /smithery.yaml: -------------------------------------------------------------------------------- ```yaml 1 | # Smithery configuration file: https://smithery.ai/docs/config#smitheryyaml 2 | 3 | startCommand: 4 | type: stdio 5 | configSchema: 6 | # JSON Schema defining the configuration options for the MCP. 7 | type: object 8 | properties: {} 9 | commandFunction: 10 | # A JS function that produces the CLI command based on the given config to start the MCP on stdio. 11 | |- 12 | (config) => ({ 13 | command: 'python', 14 | args: ['-m', 'MCP_Server.server'] 15 | }) 16 | exampleConfig: {} 17 | ``` -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- ```toml 1 | [project] 2 | name = "ableton-mcp" 3 | version = "1.0.0" 4 | description = "Ableton Live integration through the Model Context Protocol" 5 | readme = "README.md" 6 | requires-python = ">=3.10" 7 | authors = [ 8 | {name = "Siddharth Ahuja", email = "[email protected]"} 9 | ] 10 | license = {text = "MIT"} 11 | classifiers = [ 12 | "Programming Language :: Python :: 3", 13 | "License :: OSI Approved :: MIT License", 14 | "Operating System :: OS Independent", 15 | ] 16 | dependencies = [ 17 | "mcp[cli]>=1.3.0", 18 | ] 19 | 20 | [project.scripts] 21 | ableton-mcp = "MCP_Server.server:main" 22 | 23 | [build-system] 24 | requires = ["setuptools>=61.0", "wheel"] 25 | build-backend = "setuptools.build_meta" 26 | 27 | [tool.setuptools] 28 | packages = ["MCP_Server"] 29 | 30 | [project.urls] 31 | "Homepage" = "https://github.com/ahujasid/ableton-mcp" 32 | "Bug Tracker" = "https://github.com/ahujasid/ableton-mcp/issues" ``` -------------------------------------------------------------------------------- /MCP_Server/server.py: -------------------------------------------------------------------------------- ```python 1 | # ableton_mcp_server.py 2 | from mcp.server.fastmcp import FastMCP, Context 3 | import socket 4 | import json 5 | import logging 6 | from dataclasses import dataclass 7 | from contextlib import asynccontextmanager 8 | from typing import AsyncIterator, Dict, Any, List, Union 9 | 10 | # Configure logging 11 | logging.basicConfig(level=logging.INFO, 12 | format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') 13 | logger = logging.getLogger("AbletonMCPServer") 14 | 15 | @dataclass 16 | class AbletonConnection: 17 | host: str 18 | port: int 19 | sock: socket.socket = None 20 | 21 | def connect(self) -> bool: 22 | """Connect to the Ableton Remote Script socket server""" 23 | if self.sock: 24 | return True 25 | 26 | try: 27 | self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 28 | self.sock.connect((self.host, self.port)) 29 | logger.info(f"Connected to Ableton at {self.host}:{self.port}") 30 | return True 31 | except Exception as e: 32 | logger.error(f"Failed to connect to Ableton: {str(e)}") 33 | self.sock = None 34 | return False 35 | 36 | def disconnect(self): 37 | """Disconnect from the Ableton Remote Script""" 38 | if self.sock: 39 | try: 40 | self.sock.close() 41 | except Exception as e: 42 | logger.error(f"Error disconnecting from Ableton: {str(e)}") 43 | finally: 44 | self.sock = None 45 | 46 | def receive_full_response(self, sock, buffer_size=8192): 47 | """Receive the complete response, potentially in multiple chunks""" 48 | chunks = [] 49 | sock.settimeout(15.0) # Increased timeout for operations that might take longer 50 | 51 | try: 52 | while True: 53 | try: 54 | chunk = sock.recv(buffer_size) 55 | if not chunk: 56 | if not chunks: 57 | raise Exception("Connection closed before receiving any data") 58 | break 59 | 60 | chunks.append(chunk) 61 | 62 | # Check if we've received a complete JSON object 63 | try: 64 | data = b''.join(chunks) 65 | json.loads(data.decode('utf-8')) 66 | logger.info(f"Received complete response ({len(data)} bytes)") 67 | return data 68 | except json.JSONDecodeError: 69 | # Incomplete JSON, continue receiving 70 | continue 71 | except socket.timeout: 72 | logger.warning("Socket timeout during chunked receive") 73 | break 74 | except (ConnectionError, BrokenPipeError, ConnectionResetError) as e: 75 | logger.error(f"Socket connection error during receive: {str(e)}") 76 | raise 77 | except Exception as e: 78 | logger.error(f"Error during receive: {str(e)}") 79 | raise 80 | 81 | # If we get here, we either timed out or broke out of the loop 82 | if chunks: 83 | data = b''.join(chunks) 84 | logger.info(f"Returning data after receive completion ({len(data)} bytes)") 85 | try: 86 | json.loads(data.decode('utf-8')) 87 | return data 88 | except json.JSONDecodeError: 89 | raise Exception("Incomplete JSON response received") 90 | else: 91 | raise Exception("No data received") 92 | 93 | def send_command(self, command_type: str, params: Dict[str, Any] = None) -> Dict[str, Any]: 94 | """Send a command to Ableton and return the response""" 95 | if not self.sock and not self.connect(): 96 | raise ConnectionError("Not connected to Ableton") 97 | 98 | command = { 99 | "type": command_type, 100 | "params": params or {} 101 | } 102 | 103 | # Check if this is a state-modifying command 104 | is_modifying_command = command_type in [ 105 | "create_midi_track", "create_audio_track", "set_track_name", 106 | "create_clip", "add_notes_to_clip", "set_clip_name", 107 | "set_tempo", "fire_clip", "stop_clip", "set_device_parameter", 108 | "start_playback", "stop_playback", "load_instrument_or_effect" 109 | ] 110 | 111 | try: 112 | logger.info(f"Sending command: {command_type} with params: {params}") 113 | 114 | # Send the command 115 | self.sock.sendall(json.dumps(command).encode('utf-8')) 116 | logger.info(f"Command sent, waiting for response...") 117 | 118 | # For state-modifying commands, add a small delay to give Ableton time to process 119 | if is_modifying_command: 120 | import time 121 | time.sleep(0.1) # 100ms delay 122 | 123 | # Set timeout based on command type 124 | timeout = 15.0 if is_modifying_command else 10.0 125 | self.sock.settimeout(timeout) 126 | 127 | # Receive the response 128 | response_data = self.receive_full_response(self.sock) 129 | logger.info(f"Received {len(response_data)} bytes of data") 130 | 131 | # Parse the response 132 | response = json.loads(response_data.decode('utf-8')) 133 | logger.info(f"Response parsed, status: {response.get('status', 'unknown')}") 134 | 135 | if response.get("status") == "error": 136 | logger.error(f"Ableton error: {response.get('message')}") 137 | raise Exception(response.get("message", "Unknown error from Ableton")) 138 | 139 | # For state-modifying commands, add another small delay after receiving response 140 | if is_modifying_command: 141 | import time 142 | time.sleep(0.1) # 100ms delay 143 | 144 | return response.get("result", {}) 145 | except socket.timeout: 146 | logger.error("Socket timeout while waiting for response from Ableton") 147 | self.sock = None 148 | raise Exception("Timeout waiting for Ableton response") 149 | except (ConnectionError, BrokenPipeError, ConnectionResetError) as e: 150 | logger.error(f"Socket connection error: {str(e)}") 151 | self.sock = None 152 | raise Exception(f"Connection to Ableton lost: {str(e)}") 153 | except json.JSONDecodeError as e: 154 | logger.error(f"Invalid JSON response from Ableton: {str(e)}") 155 | if 'response_data' in locals() and response_data: 156 | logger.error(f"Raw response (first 200 bytes): {response_data[:200]}") 157 | self.sock = None 158 | raise Exception(f"Invalid response from Ableton: {str(e)}") 159 | except Exception as e: 160 | logger.error(f"Error communicating with Ableton: {str(e)}") 161 | self.sock = None 162 | raise Exception(f"Communication error with Ableton: {str(e)}") 163 | 164 | @asynccontextmanager 165 | async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]: 166 | """Manage server startup and shutdown lifecycle""" 167 | try: 168 | logger.info("AbletonMCP server starting up") 169 | 170 | try: 171 | ableton = get_ableton_connection() 172 | logger.info("Successfully connected to Ableton on startup") 173 | except Exception as e: 174 | logger.warning(f"Could not connect to Ableton on startup: {str(e)}") 175 | logger.warning("Make sure the Ableton Remote Script is running") 176 | 177 | yield {} 178 | finally: 179 | global _ableton_connection 180 | if _ableton_connection: 181 | logger.info("Disconnecting from Ableton on shutdown") 182 | _ableton_connection.disconnect() 183 | _ableton_connection = None 184 | logger.info("AbletonMCP server shut down") 185 | 186 | # Create the MCP server with lifespan support 187 | mcp = FastMCP( 188 | "AbletonMCP", 189 | description="Ableton Live integration through the Model Context Protocol", 190 | lifespan=server_lifespan 191 | ) 192 | 193 | # Global connection for resources 194 | _ableton_connection = None 195 | 196 | def get_ableton_connection(): 197 | """Get or create a persistent Ableton connection""" 198 | global _ableton_connection 199 | 200 | if _ableton_connection is not None: 201 | try: 202 | # Test the connection with a simple ping 203 | # We'll try to send an empty message, which should fail if the connection is dead 204 | # but won't affect Ableton if it's alive 205 | _ableton_connection.sock.settimeout(1.0) 206 | _ableton_connection.sock.sendall(b'') 207 | return _ableton_connection 208 | except Exception as e: 209 | logger.warning(f"Existing connection is no longer valid: {str(e)}") 210 | try: 211 | _ableton_connection.disconnect() 212 | except: 213 | pass 214 | _ableton_connection = None 215 | 216 | # Connection doesn't exist or is invalid, create a new one 217 | if _ableton_connection is None: 218 | # Try to connect up to 3 times with a short delay between attempts 219 | max_attempts = 3 220 | for attempt in range(1, max_attempts + 1): 221 | try: 222 | logger.info(f"Connecting to Ableton (attempt {attempt}/{max_attempts})...") 223 | _ableton_connection = AbletonConnection(host="localhost", port=9877) 224 | if _ableton_connection.connect(): 225 | logger.info("Created new persistent connection to Ableton") 226 | 227 | # Validate connection with a simple command 228 | try: 229 | # Get session info as a test 230 | _ableton_connection.send_command("get_session_info") 231 | logger.info("Connection validated successfully") 232 | return _ableton_connection 233 | except Exception as e: 234 | logger.error(f"Connection validation failed: {str(e)}") 235 | _ableton_connection.disconnect() 236 | _ableton_connection = None 237 | # Continue to next attempt 238 | else: 239 | _ableton_connection = None 240 | except Exception as e: 241 | logger.error(f"Connection attempt {attempt} failed: {str(e)}") 242 | if _ableton_connection: 243 | _ableton_connection.disconnect() 244 | _ableton_connection = None 245 | 246 | # Wait before trying again, but only if we have more attempts left 247 | if attempt < max_attempts: 248 | import time 249 | time.sleep(1.0) 250 | 251 | # If we get here, all connection attempts failed 252 | if _ableton_connection is None: 253 | logger.error("Failed to connect to Ableton after multiple attempts") 254 | raise Exception("Could not connect to Ableton. Make sure the Remote Script is running.") 255 | 256 | return _ableton_connection 257 | 258 | 259 | # Core Tool endpoints 260 | 261 | @mcp.tool() 262 | def get_session_info(ctx: Context) -> str: 263 | """Get detailed information about the current Ableton session""" 264 | try: 265 | ableton = get_ableton_connection() 266 | result = ableton.send_command("get_session_info") 267 | return json.dumps(result, indent=2) 268 | except Exception as e: 269 | logger.error(f"Error getting session info from Ableton: {str(e)}") 270 | return f"Error getting session info: {str(e)}" 271 | 272 | @mcp.tool() 273 | def get_track_info(ctx: Context, track_index: int) -> str: 274 | """ 275 | Get detailed information about a specific track in Ableton. 276 | 277 | Parameters: 278 | - track_index: The index of the track to get information about 279 | """ 280 | try: 281 | ableton = get_ableton_connection() 282 | result = ableton.send_command("get_track_info", {"track_index": track_index}) 283 | return json.dumps(result, indent=2) 284 | except Exception as e: 285 | logger.error(f"Error getting track info from Ableton: {str(e)}") 286 | return f"Error getting track info: {str(e)}" 287 | 288 | @mcp.tool() 289 | def create_midi_track(ctx: Context, index: int = -1) -> str: 290 | """ 291 | Create a new MIDI track in the Ableton session. 292 | 293 | Parameters: 294 | - index: The index to insert the track at (-1 = end of list) 295 | """ 296 | try: 297 | ableton = get_ableton_connection() 298 | result = ableton.send_command("create_midi_track", {"index": index}) 299 | return f"Created new MIDI track: {result.get('name', 'unknown')}" 300 | except Exception as e: 301 | logger.error(f"Error creating MIDI track: {str(e)}") 302 | return f"Error creating MIDI track: {str(e)}" 303 | 304 | 305 | @mcp.tool() 306 | def set_track_name(ctx: Context, track_index: int, name: str) -> str: 307 | """ 308 | Set the name of a track. 309 | 310 | Parameters: 311 | - track_index: The index of the track to rename 312 | - name: The new name for the track 313 | """ 314 | try: 315 | ableton = get_ableton_connection() 316 | result = ableton.send_command("set_track_name", {"track_index": track_index, "name": name}) 317 | return f"Renamed track to: {result.get('name', name)}" 318 | except Exception as e: 319 | logger.error(f"Error setting track name: {str(e)}") 320 | return f"Error setting track name: {str(e)}" 321 | 322 | @mcp.tool() 323 | def create_clip(ctx: Context, track_index: int, clip_index: int, length: float = 4.0) -> str: 324 | """ 325 | Create a new MIDI clip in the specified track and clip slot. 326 | 327 | Parameters: 328 | - track_index: The index of the track to create the clip in 329 | - clip_index: The index of the clip slot to create the clip in 330 | - length: The length of the clip in beats (default: 4.0) 331 | """ 332 | try: 333 | ableton = get_ableton_connection() 334 | result = ableton.send_command("create_clip", { 335 | "track_index": track_index, 336 | "clip_index": clip_index, 337 | "length": length 338 | }) 339 | return f"Created new clip at track {track_index}, slot {clip_index} with length {length} beats" 340 | except Exception as e: 341 | logger.error(f"Error creating clip: {str(e)}") 342 | return f"Error creating clip: {str(e)}" 343 | 344 | @mcp.tool() 345 | def add_notes_to_clip( 346 | ctx: Context, 347 | track_index: int, 348 | clip_index: int, 349 | notes: List[Dict[str, Union[int, float, bool]]] 350 | ) -> str: 351 | """ 352 | Add MIDI notes to a clip. 353 | 354 | Parameters: 355 | - track_index: The index of the track containing the clip 356 | - clip_index: The index of the clip slot containing the clip 357 | - notes: List of note dictionaries, each with pitch, start_time, duration, velocity, and mute 358 | """ 359 | try: 360 | ableton = get_ableton_connection() 361 | result = ableton.send_command("add_notes_to_clip", { 362 | "track_index": track_index, 363 | "clip_index": clip_index, 364 | "notes": notes 365 | }) 366 | return f"Added {len(notes)} notes to clip at track {track_index}, slot {clip_index}" 367 | except Exception as e: 368 | logger.error(f"Error adding notes to clip: {str(e)}") 369 | return f"Error adding notes to clip: {str(e)}" 370 | 371 | @mcp.tool() 372 | def set_clip_name(ctx: Context, track_index: int, clip_index: int, name: str) -> str: 373 | """ 374 | Set the name of a clip. 375 | 376 | Parameters: 377 | - track_index: The index of the track containing the clip 378 | - clip_index: The index of the clip slot containing the clip 379 | - name: The new name for the clip 380 | """ 381 | try: 382 | ableton = get_ableton_connection() 383 | result = ableton.send_command("set_clip_name", { 384 | "track_index": track_index, 385 | "clip_index": clip_index, 386 | "name": name 387 | }) 388 | return f"Renamed clip at track {track_index}, slot {clip_index} to '{name}'" 389 | except Exception as e: 390 | logger.error(f"Error setting clip name: {str(e)}") 391 | return f"Error setting clip name: {str(e)}" 392 | 393 | @mcp.tool() 394 | def set_tempo(ctx: Context, tempo: float) -> str: 395 | """ 396 | Set the tempo of the Ableton session. 397 | 398 | Parameters: 399 | - tempo: The new tempo in BPM 400 | """ 401 | try: 402 | ableton = get_ableton_connection() 403 | result = ableton.send_command("set_tempo", {"tempo": tempo}) 404 | return f"Set tempo to {tempo} BPM" 405 | except Exception as e: 406 | logger.error(f"Error setting tempo: {str(e)}") 407 | return f"Error setting tempo: {str(e)}" 408 | 409 | 410 | @mcp.tool() 411 | def load_instrument_or_effect(ctx: Context, track_index: int, uri: str) -> str: 412 | """ 413 | Load an instrument or effect onto a track using its URI. 414 | 415 | Parameters: 416 | - track_index: The index of the track to load the instrument on 417 | - uri: The URI of the instrument or effect to load (e.g., 'query:Synths#Instrument%20Rack:Bass:FileId_5116') 418 | """ 419 | try: 420 | ableton = get_ableton_connection() 421 | result = ableton.send_command("load_browser_item", { 422 | "track_index": track_index, 423 | "item_uri": uri 424 | }) 425 | 426 | # Check if the instrument was loaded successfully 427 | if result.get("loaded", False): 428 | new_devices = result.get("new_devices", []) 429 | if new_devices: 430 | return f"Loaded instrument with URI '{uri}' on track {track_index}. New devices: {', '.join(new_devices)}" 431 | else: 432 | devices = result.get("devices_after", []) 433 | return f"Loaded instrument with URI '{uri}' on track {track_index}. Devices on track: {', '.join(devices)}" 434 | else: 435 | return f"Failed to load instrument with URI '{uri}'" 436 | except Exception as e: 437 | logger.error(f"Error loading instrument by URI: {str(e)}") 438 | return f"Error loading instrument by URI: {str(e)}" 439 | 440 | @mcp.tool() 441 | def fire_clip(ctx: Context, track_index: int, clip_index: int) -> str: 442 | """ 443 | Start playing a clip. 444 | 445 | Parameters: 446 | - track_index: The index of the track containing the clip 447 | - clip_index: The index of the clip slot containing the clip 448 | """ 449 | try: 450 | ableton = get_ableton_connection() 451 | result = ableton.send_command("fire_clip", { 452 | "track_index": track_index, 453 | "clip_index": clip_index 454 | }) 455 | return f"Started playing clip at track {track_index}, slot {clip_index}" 456 | except Exception as e: 457 | logger.error(f"Error firing clip: {str(e)}") 458 | return f"Error firing clip: {str(e)}" 459 | 460 | @mcp.tool() 461 | def stop_clip(ctx: Context, track_index: int, clip_index: int) -> str: 462 | """ 463 | Stop playing a clip. 464 | 465 | Parameters: 466 | - track_index: The index of the track containing the clip 467 | - clip_index: The index of the clip slot containing the clip 468 | """ 469 | try: 470 | ableton = get_ableton_connection() 471 | result = ableton.send_command("stop_clip", { 472 | "track_index": track_index, 473 | "clip_index": clip_index 474 | }) 475 | return f"Stopped clip at track {track_index}, slot {clip_index}" 476 | except Exception as e: 477 | logger.error(f"Error stopping clip: {str(e)}") 478 | return f"Error stopping clip: {str(e)}" 479 | 480 | @mcp.tool() 481 | def start_playback(ctx: Context) -> str: 482 | """Start playing the Ableton session.""" 483 | try: 484 | ableton = get_ableton_connection() 485 | result = ableton.send_command("start_playback") 486 | return "Started playback" 487 | except Exception as e: 488 | logger.error(f"Error starting playback: {str(e)}") 489 | return f"Error starting playback: {str(e)}" 490 | 491 | @mcp.tool() 492 | def stop_playback(ctx: Context) -> str: 493 | """Stop playing the Ableton session.""" 494 | try: 495 | ableton = get_ableton_connection() 496 | result = ableton.send_command("stop_playback") 497 | return "Stopped playback" 498 | except Exception as e: 499 | logger.error(f"Error stopping playback: {str(e)}") 500 | return f"Error stopping playback: {str(e)}" 501 | 502 | @mcp.tool() 503 | def get_browser_tree(ctx: Context, category_type: str = "all") -> str: 504 | """ 505 | Get a hierarchical tree of browser categories from Ableton. 506 | 507 | Parameters: 508 | - category_type: Type of categories to get ('all', 'instruments', 'sounds', 'drums', 'audio_effects', 'midi_effects') 509 | """ 510 | try: 511 | ableton = get_ableton_connection() 512 | result = ableton.send_command("get_browser_tree", { 513 | "category_type": category_type 514 | }) 515 | 516 | # Check if we got any categories 517 | if "available_categories" in result and len(result.get("categories", [])) == 0: 518 | available_cats = result.get("available_categories", []) 519 | return (f"No categories found for '{category_type}'. " 520 | f"Available browser categories: {', '.join(available_cats)}") 521 | 522 | # Format the tree in a more readable way 523 | total_folders = result.get("total_folders", 0) 524 | formatted_output = f"Browser tree for '{category_type}' (showing {total_folders} folders):\n\n" 525 | 526 | def format_tree(item, indent=0): 527 | output = "" 528 | if item: 529 | prefix = " " * indent 530 | name = item.get("name", "Unknown") 531 | path = item.get("path", "") 532 | has_more = item.get("has_more", False) 533 | 534 | # Add this item 535 | output += f"{prefix}• {name}" 536 | if path: 537 | output += f" (path: {path})" 538 | if has_more: 539 | output += " [...]" 540 | output += "\n" 541 | 542 | # Add children 543 | for child in item.get("children", []): 544 | output += format_tree(child, indent + 1) 545 | return output 546 | 547 | # Format each category 548 | for category in result.get("categories", []): 549 | formatted_output += format_tree(category) 550 | formatted_output += "\n" 551 | 552 | return formatted_output 553 | except Exception as e: 554 | error_msg = str(e) 555 | if "Browser is not available" in error_msg: 556 | logger.error(f"Browser is not available in Ableton: {error_msg}") 557 | return f"Error: The Ableton browser is not available. Make sure Ableton Live is fully loaded and try again." 558 | elif "Could not access Live application" in error_msg: 559 | logger.error(f"Could not access Live application: {error_msg}") 560 | return f"Error: Could not access the Ableton Live application. Make sure Ableton Live is running and the Remote Script is loaded." 561 | else: 562 | logger.error(f"Error getting browser tree: {error_msg}") 563 | return f"Error getting browser tree: {error_msg}" 564 | 565 | @mcp.tool() 566 | def get_browser_items_at_path(ctx: Context, path: str) -> str: 567 | """ 568 | Get browser items at a specific path in Ableton's browser. 569 | 570 | Parameters: 571 | - path: Path in the format "category/folder/subfolder" 572 | where category is one of the available browser categories in Ableton 573 | """ 574 | try: 575 | ableton = get_ableton_connection() 576 | result = ableton.send_command("get_browser_items_at_path", { 577 | "path": path 578 | }) 579 | 580 | # Check if there was an error with available categories 581 | if "error" in result and "available_categories" in result: 582 | error = result.get("error", "") 583 | available_cats = result.get("available_categories", []) 584 | return (f"Error: {error}\n" 585 | f"Available browser categories: {', '.join(available_cats)}") 586 | 587 | return json.dumps(result, indent=2) 588 | except Exception as e: 589 | error_msg = str(e) 590 | if "Browser is not available" in error_msg: 591 | logger.error(f"Browser is not available in Ableton: {error_msg}") 592 | return f"Error: The Ableton browser is not available. Make sure Ableton Live is fully loaded and try again." 593 | elif "Could not access Live application" in error_msg: 594 | logger.error(f"Could not access Live application: {error_msg}") 595 | return f"Error: Could not access the Ableton Live application. Make sure Ableton Live is running and the Remote Script is loaded." 596 | elif "Unknown or unavailable category" in error_msg: 597 | logger.error(f"Invalid browser category: {error_msg}") 598 | return f"Error: {error_msg}. Please check the available categories using get_browser_tree." 599 | elif "Path part" in error_msg and "not found" in error_msg: 600 | logger.error(f"Path not found: {error_msg}") 601 | return f"Error: {error_msg}. Please check the path and try again." 602 | else: 603 | logger.error(f"Error getting browser items at path: {error_msg}") 604 | return f"Error getting browser items at path: {error_msg}" 605 | 606 | @mcp.tool() 607 | def load_drum_kit(ctx: Context, track_index: int, rack_uri: str, kit_path: str) -> str: 608 | """ 609 | Load a drum rack and then load a specific drum kit into it. 610 | 611 | Parameters: 612 | - track_index: The index of the track to load on 613 | - rack_uri: The URI of the drum rack to load (e.g., 'Drums/Drum Rack') 614 | - kit_path: Path to the drum kit inside the browser (e.g., 'drums/acoustic/kit1') 615 | """ 616 | try: 617 | ableton = get_ableton_connection() 618 | 619 | # Step 1: Load the drum rack 620 | result = ableton.send_command("load_browser_item", { 621 | "track_index": track_index, 622 | "item_uri": rack_uri 623 | }) 624 | 625 | if not result.get("loaded", False): 626 | return f"Failed to load drum rack with URI '{rack_uri}'" 627 | 628 | # Step 2: Get the drum kit items at the specified path 629 | kit_result = ableton.send_command("get_browser_items_at_path", { 630 | "path": kit_path 631 | }) 632 | 633 | if "error" in kit_result: 634 | return f"Loaded drum rack but failed to find drum kit: {kit_result.get('error')}" 635 | 636 | # Step 3: Find a loadable drum kit 637 | kit_items = kit_result.get("items", []) 638 | loadable_kits = [item for item in kit_items if item.get("is_loadable", False)] 639 | 640 | if not loadable_kits: 641 | return f"Loaded drum rack but no loadable drum kits found at '{kit_path}'" 642 | 643 | # Step 4: Load the first loadable kit 644 | kit_uri = loadable_kits[0].get("uri") 645 | load_result = ableton.send_command("load_browser_item", { 646 | "track_index": track_index, 647 | "item_uri": kit_uri 648 | }) 649 | 650 | return f"Loaded drum rack and kit '{loadable_kits[0].get('name')}' on track {track_index}" 651 | except Exception as e: 652 | logger.error(f"Error loading drum kit: {str(e)}") 653 | return f"Error loading drum kit: {str(e)}" 654 | 655 | # Main execution 656 | def main(): 657 | """Run the MCP server""" 658 | mcp.run() 659 | 660 | if __name__ == "__main__": 661 | main() ``` -------------------------------------------------------------------------------- /AbletonMCP_Remote_Script/__init__.py: -------------------------------------------------------------------------------- ```python 1 | # AbletonMCP/init.py 2 | from __future__ import absolute_import, print_function, unicode_literals 3 | 4 | from _Framework.ControlSurface import ControlSurface 5 | import socket 6 | import json 7 | import threading 8 | import time 9 | import traceback 10 | 11 | # Change queue import for Python 2 12 | try: 13 | import Queue as queue # Python 2 14 | except ImportError: 15 | import queue # Python 3 16 | 17 | # Constants for socket communication 18 | DEFAULT_PORT = 9877 19 | HOST = "localhost" 20 | 21 | def create_instance(c_instance): 22 | """Create and return the AbletonMCP script instance""" 23 | return AbletonMCP(c_instance) 24 | 25 | class AbletonMCP(ControlSurface): 26 | """AbletonMCP Remote Script for Ableton Live""" 27 | 28 | def __init__(self, c_instance): 29 | """Initialize the control surface""" 30 | ControlSurface.__init__(self, c_instance) 31 | self.log_message("AbletonMCP Remote Script initializing...") 32 | 33 | # Socket server for communication 34 | self.server = None 35 | self.client_threads = [] 36 | self.server_thread = None 37 | self.running = False 38 | 39 | # Cache the song reference for easier access 40 | self._song = self.song() 41 | 42 | # Start the socket server 43 | self.start_server() 44 | 45 | self.log_message("AbletonMCP initialized") 46 | 47 | # Show a message in Ableton 48 | self.show_message("AbletonMCP: Listening for commands on port " + str(DEFAULT_PORT)) 49 | 50 | def disconnect(self): 51 | """Called when Ableton closes or the control surface is removed""" 52 | self.log_message("AbletonMCP disconnecting...") 53 | self.running = False 54 | 55 | # Stop the server 56 | if self.server: 57 | try: 58 | self.server.close() 59 | except: 60 | pass 61 | 62 | # Wait for the server thread to exit 63 | if self.server_thread and self.server_thread.is_alive(): 64 | self.server_thread.join(1.0) 65 | 66 | # Clean up any client threads 67 | for client_thread in self.client_threads[:]: 68 | if client_thread.is_alive(): 69 | # We don't join them as they might be stuck 70 | self.log_message("Client thread still alive during disconnect") 71 | 72 | ControlSurface.disconnect(self) 73 | self.log_message("AbletonMCP disconnected") 74 | 75 | def start_server(self): 76 | """Start the socket server in a separate thread""" 77 | try: 78 | self.server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 79 | self.server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 80 | self.server.bind((HOST, DEFAULT_PORT)) 81 | self.server.listen(5) # Allow up to 5 pending connections 82 | 83 | self.running = True 84 | self.server_thread = threading.Thread(target=self._server_thread) 85 | self.server_thread.daemon = True 86 | self.server_thread.start() 87 | 88 | self.log_message("Server started on port " + str(DEFAULT_PORT)) 89 | except Exception as e: 90 | self.log_message("Error starting server: " + str(e)) 91 | self.show_message("AbletonMCP: Error starting server - " + str(e)) 92 | 93 | def _server_thread(self): 94 | """Server thread implementation - handles client connections""" 95 | try: 96 | self.log_message("Server thread started") 97 | # Set a timeout to allow regular checking of running flag 98 | self.server.settimeout(1.0) 99 | 100 | while self.running: 101 | try: 102 | # Accept connections with timeout 103 | client, address = self.server.accept() 104 | self.log_message("Connection accepted from " + str(address)) 105 | self.show_message("AbletonMCP: Client connected") 106 | 107 | # Handle client in a separate thread 108 | client_thread = threading.Thread( 109 | target=self._handle_client, 110 | args=(client,) 111 | ) 112 | client_thread.daemon = True 113 | client_thread.start() 114 | 115 | # Keep track of client threads 116 | self.client_threads.append(client_thread) 117 | 118 | # Clean up finished client threads 119 | self.client_threads = [t for t in self.client_threads if t.is_alive()] 120 | 121 | except socket.timeout: 122 | # No connection yet, just continue 123 | continue 124 | except Exception as e: 125 | if self.running: # Only log if still running 126 | self.log_message("Server accept error: " + str(e)) 127 | time.sleep(0.5) 128 | 129 | self.log_message("Server thread stopped") 130 | except Exception as e: 131 | self.log_message("Server thread error: " + str(e)) 132 | 133 | def _handle_client(self, client): 134 | """Handle communication with a connected client""" 135 | self.log_message("Client handler started") 136 | client.settimeout(None) # No timeout for client socket 137 | buffer = '' # Changed from b'' to '' for Python 2 138 | 139 | try: 140 | while self.running: 141 | try: 142 | # Receive data 143 | data = client.recv(8192) 144 | 145 | if not data: 146 | # Client disconnected 147 | self.log_message("Client disconnected") 148 | break 149 | 150 | # Accumulate data in buffer with explicit encoding/decoding 151 | try: 152 | # Python 3: data is bytes, decode to string 153 | buffer += data.decode('utf-8') 154 | except AttributeError: 155 | # Python 2: data is already string 156 | buffer += data 157 | 158 | try: 159 | # Try to parse command from buffer 160 | command = json.loads(buffer) # Removed decode('utf-8') 161 | buffer = '' # Clear buffer after successful parse 162 | 163 | self.log_message("Received command: " + str(command.get("type", "unknown"))) 164 | 165 | # Process the command and get response 166 | response = self._process_command(command) 167 | 168 | # Send the response with explicit encoding 169 | try: 170 | # Python 3: encode string to bytes 171 | client.sendall(json.dumps(response).encode('utf-8')) 172 | except AttributeError: 173 | # Python 2: string is already bytes 174 | client.sendall(json.dumps(response)) 175 | except ValueError: 176 | # Incomplete data, wait for more 177 | continue 178 | 179 | except Exception as e: 180 | self.log_message("Error handling client data: " + str(e)) 181 | self.log_message(traceback.format_exc()) 182 | 183 | # Send error response if possible 184 | error_response = { 185 | "status": "error", 186 | "message": str(e) 187 | } 188 | try: 189 | # Python 3: encode string to bytes 190 | client.sendall(json.dumps(error_response).encode('utf-8')) 191 | except AttributeError: 192 | # Python 2: string is already bytes 193 | client.sendall(json.dumps(error_response)) 194 | except: 195 | # If we can't send the error, the connection is probably dead 196 | break 197 | 198 | # For serious errors, break the loop 199 | if not isinstance(e, ValueError): 200 | break 201 | except Exception as e: 202 | self.log_message("Error in client handler: " + str(e)) 203 | finally: 204 | try: 205 | client.close() 206 | except: 207 | pass 208 | self.log_message("Client handler stopped") 209 | 210 | def _process_command(self, command): 211 | """Process a command from the client and return a response""" 212 | command_type = command.get("type", "") 213 | params = command.get("params", {}) 214 | 215 | # Initialize response 216 | response = { 217 | "status": "success", 218 | "result": {} 219 | } 220 | 221 | try: 222 | # Route the command to the appropriate handler 223 | if command_type == "get_session_info": 224 | response["result"] = self._get_session_info() 225 | elif command_type == "get_track_info": 226 | track_index = params.get("track_index", 0) 227 | response["result"] = self._get_track_info(track_index) 228 | # Commands that modify Live's state should be scheduled on the main thread 229 | elif command_type in ["create_midi_track", "set_track_name", 230 | "create_clip", "add_notes_to_clip", "set_clip_name", 231 | "set_tempo", "fire_clip", "stop_clip", 232 | "start_playback", "stop_playback", "load_browser_item"]: 233 | # Use a thread-safe approach with a response queue 234 | response_queue = queue.Queue() 235 | 236 | # Define a function to execute on the main thread 237 | def main_thread_task(): 238 | try: 239 | result = None 240 | if command_type == "create_midi_track": 241 | index = params.get("index", -1) 242 | result = self._create_midi_track(index) 243 | elif command_type == "set_track_name": 244 | track_index = params.get("track_index", 0) 245 | name = params.get("name", "") 246 | result = self._set_track_name(track_index, name) 247 | elif command_type == "create_clip": 248 | track_index = params.get("track_index", 0) 249 | clip_index = params.get("clip_index", 0) 250 | length = params.get("length", 4.0) 251 | result = self._create_clip(track_index, clip_index, length) 252 | elif command_type == "add_notes_to_clip": 253 | track_index = params.get("track_index", 0) 254 | clip_index = params.get("clip_index", 0) 255 | notes = params.get("notes", []) 256 | result = self._add_notes_to_clip(track_index, clip_index, notes) 257 | elif command_type == "set_clip_name": 258 | track_index = params.get("track_index", 0) 259 | clip_index = params.get("clip_index", 0) 260 | name = params.get("name", "") 261 | result = self._set_clip_name(track_index, clip_index, name) 262 | elif command_type == "set_tempo": 263 | tempo = params.get("tempo", 120.0) 264 | result = self._set_tempo(tempo) 265 | elif command_type == "fire_clip": 266 | track_index = params.get("track_index", 0) 267 | clip_index = params.get("clip_index", 0) 268 | result = self._fire_clip(track_index, clip_index) 269 | elif command_type == "stop_clip": 270 | track_index = params.get("track_index", 0) 271 | clip_index = params.get("clip_index", 0) 272 | result = self._stop_clip(track_index, clip_index) 273 | elif command_type == "start_playback": 274 | result = self._start_playback() 275 | elif command_type == "stop_playback": 276 | result = self._stop_playback() 277 | elif command_type == "load_instrument_or_effect": 278 | track_index = params.get("track_index", 0) 279 | uri = params.get("uri", "") 280 | result = self._load_instrument_or_effect(track_index, uri) 281 | elif command_type == "load_browser_item": 282 | track_index = params.get("track_index", 0) 283 | item_uri = params.get("item_uri", "") 284 | result = self._load_browser_item(track_index, item_uri) 285 | 286 | # Put the result in the queue 287 | response_queue.put({"status": "success", "result": result}) 288 | except Exception as e: 289 | self.log_message("Error in main thread task: " + str(e)) 290 | self.log_message(traceback.format_exc()) 291 | response_queue.put({"status": "error", "message": str(e)}) 292 | 293 | # Schedule the task to run on the main thread 294 | try: 295 | self.schedule_message(0, main_thread_task) 296 | except AssertionError: 297 | # If we're already on the main thread, execute directly 298 | main_thread_task() 299 | 300 | # Wait for the response with a timeout 301 | try: 302 | task_response = response_queue.get(timeout=10.0) 303 | if task_response.get("status") == "error": 304 | response["status"] = "error" 305 | response["message"] = task_response.get("message", "Unknown error") 306 | else: 307 | response["result"] = task_response.get("result", {}) 308 | except queue.Empty: 309 | response["status"] = "error" 310 | response["message"] = "Timeout waiting for operation to complete" 311 | elif command_type == "get_browser_item": 312 | uri = params.get("uri", None) 313 | path = params.get("path", None) 314 | response["result"] = self._get_browser_item(uri, path) 315 | elif command_type == "get_browser_categories": 316 | category_type = params.get("category_type", "all") 317 | response["result"] = self._get_browser_categories(category_type) 318 | elif command_type == "get_browser_items": 319 | path = params.get("path", "") 320 | item_type = params.get("item_type", "all") 321 | response["result"] = self._get_browser_items(path, item_type) 322 | # Add the new browser commands 323 | elif command_type == "get_browser_tree": 324 | category_type = params.get("category_type", "all") 325 | response["result"] = self.get_browser_tree(category_type) 326 | elif command_type == "get_browser_items_at_path": 327 | path = params.get("path", "") 328 | response["result"] = self.get_browser_items_at_path(path) 329 | else: 330 | response["status"] = "error" 331 | response["message"] = "Unknown command: " + command_type 332 | except Exception as e: 333 | self.log_message("Error processing command: " + str(e)) 334 | self.log_message(traceback.format_exc()) 335 | response["status"] = "error" 336 | response["message"] = str(e) 337 | 338 | return response 339 | 340 | # Command implementations 341 | 342 | def _get_session_info(self): 343 | """Get information about the current session""" 344 | try: 345 | result = { 346 | "tempo": self._song.tempo, 347 | "signature_numerator": self._song.signature_numerator, 348 | "signature_denominator": self._song.signature_denominator, 349 | "track_count": len(self._song.tracks), 350 | "return_track_count": len(self._song.return_tracks), 351 | "master_track": { 352 | "name": "Master", 353 | "volume": self._song.master_track.mixer_device.volume.value, 354 | "panning": self._song.master_track.mixer_device.panning.value 355 | } 356 | } 357 | return result 358 | except Exception as e: 359 | self.log_message("Error getting session info: " + str(e)) 360 | raise 361 | 362 | def _get_track_info(self, track_index): 363 | """Get information about a track""" 364 | try: 365 | if track_index < 0 or track_index >= len(self._song.tracks): 366 | raise IndexError("Track index out of range") 367 | 368 | track = self._song.tracks[track_index] 369 | 370 | # Get clip slots 371 | clip_slots = [] 372 | for slot_index, slot in enumerate(track.clip_slots): 373 | clip_info = None 374 | if slot.has_clip: 375 | clip = slot.clip 376 | clip_info = { 377 | "name": clip.name, 378 | "length": clip.length, 379 | "is_playing": clip.is_playing, 380 | "is_recording": clip.is_recording 381 | } 382 | 383 | clip_slots.append({ 384 | "index": slot_index, 385 | "has_clip": slot.has_clip, 386 | "clip": clip_info 387 | }) 388 | 389 | # Get devices 390 | devices = [] 391 | for device_index, device in enumerate(track.devices): 392 | devices.append({ 393 | "index": device_index, 394 | "name": device.name, 395 | "class_name": device.class_name, 396 | "type": self._get_device_type(device) 397 | }) 398 | 399 | result = { 400 | "index": track_index, 401 | "name": track.name, 402 | "is_audio_track": track.has_audio_input, 403 | "is_midi_track": track.has_midi_input, 404 | "mute": track.mute, 405 | "solo": track.solo, 406 | "arm": track.arm, 407 | "volume": track.mixer_device.volume.value, 408 | "panning": track.mixer_device.panning.value, 409 | "clip_slots": clip_slots, 410 | "devices": devices 411 | } 412 | return result 413 | except Exception as e: 414 | self.log_message("Error getting track info: " + str(e)) 415 | raise 416 | 417 | def _create_midi_track(self, index): 418 | """Create a new MIDI track at the specified index""" 419 | try: 420 | # Create the track 421 | self._song.create_midi_track(index) 422 | 423 | # Get the new track 424 | new_track_index = len(self._song.tracks) - 1 if index == -1 else index 425 | new_track = self._song.tracks[new_track_index] 426 | 427 | result = { 428 | "index": new_track_index, 429 | "name": new_track.name 430 | } 431 | return result 432 | except Exception as e: 433 | self.log_message("Error creating MIDI track: " + str(e)) 434 | raise 435 | 436 | 437 | def _set_track_name(self, track_index, name): 438 | """Set the name of a track""" 439 | try: 440 | if track_index < 0 or track_index >= len(self._song.tracks): 441 | raise IndexError("Track index out of range") 442 | 443 | # Set the name 444 | track = self._song.tracks[track_index] 445 | track.name = name 446 | 447 | result = { 448 | "name": track.name 449 | } 450 | return result 451 | except Exception as e: 452 | self.log_message("Error setting track name: " + str(e)) 453 | raise 454 | 455 | def _create_clip(self, track_index, clip_index, length): 456 | """Create a new MIDI clip in the specified track and clip slot""" 457 | try: 458 | if track_index < 0 or track_index >= len(self._song.tracks): 459 | raise IndexError("Track index out of range") 460 | 461 | track = self._song.tracks[track_index] 462 | 463 | if clip_index < 0 or clip_index >= len(track.clip_slots): 464 | raise IndexError("Clip index out of range") 465 | 466 | clip_slot = track.clip_slots[clip_index] 467 | 468 | # Check if the clip slot already has a clip 469 | if clip_slot.has_clip: 470 | raise Exception("Clip slot already has a clip") 471 | 472 | # Create the clip 473 | clip_slot.create_clip(length) 474 | 475 | result = { 476 | "name": clip_slot.clip.name, 477 | "length": clip_slot.clip.length 478 | } 479 | return result 480 | except Exception as e: 481 | self.log_message("Error creating clip: " + str(e)) 482 | raise 483 | 484 | def _add_notes_to_clip(self, track_index, clip_index, notes): 485 | """Add MIDI notes to a clip""" 486 | try: 487 | if track_index < 0 or track_index >= len(self._song.tracks): 488 | raise IndexError("Track index out of range") 489 | 490 | track = self._song.tracks[track_index] 491 | 492 | if clip_index < 0 or clip_index >= len(track.clip_slots): 493 | raise IndexError("Clip index out of range") 494 | 495 | clip_slot = track.clip_slots[clip_index] 496 | 497 | if not clip_slot.has_clip: 498 | raise Exception("No clip in slot") 499 | 500 | clip = clip_slot.clip 501 | 502 | # Convert note data to Live's format 503 | live_notes = [] 504 | for note in notes: 505 | pitch = note.get("pitch", 60) 506 | start_time = note.get("start_time", 0.0) 507 | duration = note.get("duration", 0.25) 508 | velocity = note.get("velocity", 100) 509 | mute = note.get("mute", False) 510 | 511 | live_notes.append((pitch, start_time, duration, velocity, mute)) 512 | 513 | # Add the notes 514 | clip.set_notes(tuple(live_notes)) 515 | 516 | result = { 517 | "note_count": len(notes) 518 | } 519 | return result 520 | except Exception as e: 521 | self.log_message("Error adding notes to clip: " + str(e)) 522 | raise 523 | 524 | def _set_clip_name(self, track_index, clip_index, name): 525 | """Set the name of a clip""" 526 | try: 527 | if track_index < 0 or track_index >= len(self._song.tracks): 528 | raise IndexError("Track index out of range") 529 | 530 | track = self._song.tracks[track_index] 531 | 532 | if clip_index < 0 or clip_index >= len(track.clip_slots): 533 | raise IndexError("Clip index out of range") 534 | 535 | clip_slot = track.clip_slots[clip_index] 536 | 537 | if not clip_slot.has_clip: 538 | raise Exception("No clip in slot") 539 | 540 | clip = clip_slot.clip 541 | clip.name = name 542 | 543 | result = { 544 | "name": clip.name 545 | } 546 | return result 547 | except Exception as e: 548 | self.log_message("Error setting clip name: " + str(e)) 549 | raise 550 | 551 | def _set_tempo(self, tempo): 552 | """Set the tempo of the session""" 553 | try: 554 | self._song.tempo = tempo 555 | 556 | result = { 557 | "tempo": self._song.tempo 558 | } 559 | return result 560 | except Exception as e: 561 | self.log_message("Error setting tempo: " + str(e)) 562 | raise 563 | 564 | def _fire_clip(self, track_index, clip_index): 565 | """Fire a clip""" 566 | try: 567 | if track_index < 0 or track_index >= len(self._song.tracks): 568 | raise IndexError("Track index out of range") 569 | 570 | track = self._song.tracks[track_index] 571 | 572 | if clip_index < 0 or clip_index >= len(track.clip_slots): 573 | raise IndexError("Clip index out of range") 574 | 575 | clip_slot = track.clip_slots[clip_index] 576 | 577 | if not clip_slot.has_clip: 578 | raise Exception("No clip in slot") 579 | 580 | clip_slot.fire() 581 | 582 | result = { 583 | "fired": True 584 | } 585 | return result 586 | except Exception as e: 587 | self.log_message("Error firing clip: " + str(e)) 588 | raise 589 | 590 | def _stop_clip(self, track_index, clip_index): 591 | """Stop a clip""" 592 | try: 593 | if track_index < 0 or track_index >= len(self._song.tracks): 594 | raise IndexError("Track index out of range") 595 | 596 | track = self._song.tracks[track_index] 597 | 598 | if clip_index < 0 or clip_index >= len(track.clip_slots): 599 | raise IndexError("Clip index out of range") 600 | 601 | clip_slot = track.clip_slots[clip_index] 602 | 603 | clip_slot.stop() 604 | 605 | result = { 606 | "stopped": True 607 | } 608 | return result 609 | except Exception as e: 610 | self.log_message("Error stopping clip: " + str(e)) 611 | raise 612 | 613 | 614 | def _start_playback(self): 615 | """Start playing the session""" 616 | try: 617 | self._song.start_playing() 618 | 619 | result = { 620 | "playing": self._song.is_playing 621 | } 622 | return result 623 | except Exception as e: 624 | self.log_message("Error starting playback: " + str(e)) 625 | raise 626 | 627 | def _stop_playback(self): 628 | """Stop playing the session""" 629 | try: 630 | self._song.stop_playing() 631 | 632 | result = { 633 | "playing": self._song.is_playing 634 | } 635 | return result 636 | except Exception as e: 637 | self.log_message("Error stopping playback: " + str(e)) 638 | raise 639 | 640 | def _get_browser_item(self, uri, path): 641 | """Get a browser item by URI or path""" 642 | try: 643 | # Access the application's browser instance instead of creating a new one 644 | app = self.application() 645 | if not app: 646 | raise RuntimeError("Could not access Live application") 647 | 648 | result = { 649 | "uri": uri, 650 | "path": path, 651 | "found": False 652 | } 653 | 654 | # Try to find by URI first if provided 655 | if uri: 656 | item = self._find_browser_item_by_uri(app.browser, uri) 657 | if item: 658 | result["found"] = True 659 | result["item"] = { 660 | "name": item.name, 661 | "is_folder": item.is_folder, 662 | "is_device": item.is_device, 663 | "is_loadable": item.is_loadable, 664 | "uri": item.uri 665 | } 666 | return result 667 | 668 | # If URI not provided or not found, try by path 669 | if path: 670 | # Parse the path and navigate to the specified item 671 | path_parts = path.split("/") 672 | 673 | # Determine the root based on the first part 674 | current_item = None 675 | if path_parts[0].lower() == "nstruments": 676 | current_item = app.browser.instruments 677 | elif path_parts[0].lower() == "sounds": 678 | current_item = app.browser.sounds 679 | elif path_parts[0].lower() == "drums": 680 | current_item = app.browser.drums 681 | elif path_parts[0].lower() == "audio_effects": 682 | current_item = app.browser.audio_effects 683 | elif path_parts[0].lower() == "midi_effects": 684 | current_item = app.browser.midi_effects 685 | else: 686 | # Default to instruments if not specified 687 | current_item = app.browser.instruments 688 | # Don't skip the first part in this case 689 | path_parts = ["instruments"] + path_parts 690 | 691 | # Navigate through the path 692 | for i in range(1, len(path_parts)): 693 | part = path_parts[i] 694 | if not part: # Skip empty parts 695 | continue 696 | 697 | found = False 698 | for child in current_item.children: 699 | if child.name.lower() == part.lower(): 700 | current_item = child 701 | found = True 702 | break 703 | 704 | if not found: 705 | result["error"] = "Path part '{0}' not found".format(part) 706 | return result 707 | 708 | # Found the item 709 | result["found"] = True 710 | result["item"] = { 711 | "name": current_item.name, 712 | "is_folder": current_item.is_folder, 713 | "is_device": current_item.is_device, 714 | "is_loadable": current_item.is_loadable, 715 | "uri": current_item.uri 716 | } 717 | 718 | return result 719 | except Exception as e: 720 | self.log_message("Error getting browser item: " + str(e)) 721 | self.log_message(traceback.format_exc()) 722 | raise 723 | 724 | 725 | 726 | def _load_browser_item(self, track_index, item_uri): 727 | """Load a browser item onto a track by its URI""" 728 | try: 729 | if track_index < 0 or track_index >= len(self._song.tracks): 730 | raise IndexError("Track index out of range") 731 | 732 | track = self._song.tracks[track_index] 733 | 734 | # Access the application's browser instance instead of creating a new one 735 | app = self.application() 736 | 737 | # Find the browser item by URI 738 | item = self._find_browser_item_by_uri(app.browser, item_uri) 739 | 740 | if not item: 741 | raise ValueError("Browser item with URI '{0}' not found".format(item_uri)) 742 | 743 | # Select the track 744 | self._song.view.selected_track = track 745 | 746 | # Load the item 747 | app.browser.load_item(item) 748 | 749 | result = { 750 | "loaded": True, 751 | "item_name": item.name, 752 | "track_name": track.name, 753 | "uri": item_uri 754 | } 755 | return result 756 | except Exception as e: 757 | self.log_message("Error loading browser item: {0}".format(str(e))) 758 | self.log_message(traceback.format_exc()) 759 | raise 760 | 761 | def _find_browser_item_by_uri(self, browser_or_item, uri, max_depth=10, current_depth=0): 762 | """Find a browser item by its URI""" 763 | try: 764 | # Check if this is the item we're looking for 765 | if hasattr(browser_or_item, 'uri') and browser_or_item.uri == uri: 766 | return browser_or_item 767 | 768 | # Stop recursion if we've reached max depth 769 | if current_depth >= max_depth: 770 | return None 771 | 772 | # Check if this is a browser with root categories 773 | if hasattr(browser_or_item, 'instruments'): 774 | # Check all main categories 775 | categories = [ 776 | browser_or_item.instruments, 777 | browser_or_item.sounds, 778 | browser_or_item.drums, 779 | browser_or_item.audio_effects, 780 | browser_or_item.midi_effects 781 | ] 782 | 783 | for category in categories: 784 | item = self._find_browser_item_by_uri(category, uri, max_depth, current_depth + 1) 785 | if item: 786 | return item 787 | 788 | return None 789 | 790 | # Check if this item has children 791 | if hasattr(browser_or_item, 'children') and browser_or_item.children: 792 | for child in browser_or_item.children: 793 | item = self._find_browser_item_by_uri(child, uri, max_depth, current_depth + 1) 794 | if item: 795 | return item 796 | 797 | return None 798 | except Exception as e: 799 | self.log_message("Error finding browser item by URI: {0}".format(str(e))) 800 | return None 801 | 802 | # Helper methods 803 | 804 | def _get_device_type(self, device): 805 | """Get the type of a device""" 806 | try: 807 | # Simple heuristic - in a real implementation you'd look at the device class 808 | if device.can_have_drum_pads: 809 | return "drum_machine" 810 | elif device.can_have_chains: 811 | return "rack" 812 | elif "instrument" in device.class_display_name.lower(): 813 | return "instrument" 814 | elif "audio_effect" in device.class_name.lower(): 815 | return "audio_effect" 816 | elif "midi_effect" in device.class_name.lower(): 817 | return "midi_effect" 818 | else: 819 | return "unknown" 820 | except: 821 | return "unknown" 822 | 823 | def get_browser_tree(self, category_type="all"): 824 | """ 825 | Get a simplified tree of browser categories. 826 | 827 | Args: 828 | category_type: Type of categories to get ('all', 'instruments', 'sounds', etc.) 829 | 830 | Returns: 831 | Dictionary with the browser tree structure 832 | """ 833 | try: 834 | # Access the application's browser instance instead of creating a new one 835 | app = self.application() 836 | if not app: 837 | raise RuntimeError("Could not access Live application") 838 | 839 | # Check if browser is available 840 | if not hasattr(app, 'browser') or app.browser is None: 841 | raise RuntimeError("Browser is not available in the Live application") 842 | 843 | # Log available browser attributes to help diagnose issues 844 | browser_attrs = [attr for attr in dir(app.browser) if not attr.startswith('_')] 845 | self.log_message("Available browser attributes: {0}".format(browser_attrs)) 846 | 847 | result = { 848 | "type": category_type, 849 | "categories": [], 850 | "available_categories": browser_attrs 851 | } 852 | 853 | # Helper function to process a browser item and its children 854 | def process_item(item, depth=0): 855 | if not item: 856 | return None 857 | 858 | result = { 859 | "name": item.name if hasattr(item, 'name') else "Unknown", 860 | "is_folder": hasattr(item, 'children') and bool(item.children), 861 | "is_device": hasattr(item, 'is_device') and item.is_device, 862 | "is_loadable": hasattr(item, 'is_loadable') and item.is_loadable, 863 | "uri": item.uri if hasattr(item, 'uri') else None, 864 | "children": [] 865 | } 866 | 867 | 868 | return result 869 | 870 | # Process based on category type and available attributes 871 | if (category_type == "all" or category_type == "instruments") and hasattr(app.browser, 'instruments'): 872 | try: 873 | instruments = process_item(app.browser.instruments) 874 | if instruments: 875 | instruments["name"] = "Instruments" # Ensure consistent naming 876 | result["categories"].append(instruments) 877 | except Exception as e: 878 | self.log_message("Error processing instruments: {0}".format(str(e))) 879 | 880 | if (category_type == "all" or category_type == "sounds") and hasattr(app.browser, 'sounds'): 881 | try: 882 | sounds = process_item(app.browser.sounds) 883 | if sounds: 884 | sounds["name"] = "Sounds" # Ensure consistent naming 885 | result["categories"].append(sounds) 886 | except Exception as e: 887 | self.log_message("Error processing sounds: {0}".format(str(e))) 888 | 889 | if (category_type == "all" or category_type == "drums") and hasattr(app.browser, 'drums'): 890 | try: 891 | drums = process_item(app.browser.drums) 892 | if drums: 893 | drums["name"] = "Drums" # Ensure consistent naming 894 | result["categories"].append(drums) 895 | except Exception as e: 896 | self.log_message("Error processing drums: {0}".format(str(e))) 897 | 898 | if (category_type == "all" or category_type == "audio_effects") and hasattr(app.browser, 'audio_effects'): 899 | try: 900 | audio_effects = process_item(app.browser.audio_effects) 901 | if audio_effects: 902 | audio_effects["name"] = "Audio Effects" # Ensure consistent naming 903 | result["categories"].append(audio_effects) 904 | except Exception as e: 905 | self.log_message("Error processing audio_effects: {0}".format(str(e))) 906 | 907 | if (category_type == "all" or category_type == "midi_effects") and hasattr(app.browser, 'midi_effects'): 908 | try: 909 | midi_effects = process_item(app.browser.midi_effects) 910 | if midi_effects: 911 | midi_effects["name"] = "MIDI Effects" 912 | result["categories"].append(midi_effects) 913 | except Exception as e: 914 | self.log_message("Error processing midi_effects: {0}".format(str(e))) 915 | 916 | # Try to process other potentially available categories 917 | for attr in browser_attrs: 918 | if attr not in ['instruments', 'sounds', 'drums', 'audio_effects', 'midi_effects'] and \ 919 | (category_type == "all" or category_type == attr): 920 | try: 921 | item = getattr(app.browser, attr) 922 | if hasattr(item, 'children') or hasattr(item, 'name'): 923 | category = process_item(item) 924 | if category: 925 | category["name"] = attr.capitalize() 926 | result["categories"].append(category) 927 | except Exception as e: 928 | self.log_message("Error processing {0}: {1}".format(attr, str(e))) 929 | 930 | self.log_message("Browser tree generated for {0} with {1} root categories".format( 931 | category_type, len(result['categories']))) 932 | return result 933 | 934 | except Exception as e: 935 | self.log_message("Error getting browser tree: {0}".format(str(e))) 936 | self.log_message(traceback.format_exc()) 937 | raise 938 | 939 | def get_browser_items_at_path(self, path): 940 | """ 941 | Get browser items at a specific path. 942 | 943 | Args: 944 | path: Path in the format "category/folder/subfolder" 945 | where category is one of: instruments, sounds, drums, audio_effects, midi_effects 946 | or any other available browser category 947 | 948 | Returns: 949 | Dictionary with items at the specified path 950 | """ 951 | try: 952 | # Access the application's browser instance instead of creating a new one 953 | app = self.application() 954 | if not app: 955 | raise RuntimeError("Could not access Live application") 956 | 957 | # Check if browser is available 958 | if not hasattr(app, 'browser') or app.browser is None: 959 | raise RuntimeError("Browser is not available in the Live application") 960 | 961 | # Log available browser attributes to help diagnose issues 962 | browser_attrs = [attr for attr in dir(app.browser) if not attr.startswith('_')] 963 | self.log_message("Available browser attributes: {0}".format(browser_attrs)) 964 | 965 | # Parse the path 966 | path_parts = path.split("/") 967 | if not path_parts: 968 | raise ValueError("Invalid path") 969 | 970 | # Determine the root category 971 | root_category = path_parts[0].lower() 972 | current_item = None 973 | 974 | # Check standard categories first 975 | if root_category == "instruments" and hasattr(app.browser, 'instruments'): 976 | current_item = app.browser.instruments 977 | elif root_category == "sounds" and hasattr(app.browser, 'sounds'): 978 | current_item = app.browser.sounds 979 | elif root_category == "drums" and hasattr(app.browser, 'drums'): 980 | current_item = app.browser.drums 981 | elif root_category == "audio_effects" and hasattr(app.browser, 'audio_effects'): 982 | current_item = app.browser.audio_effects 983 | elif root_category == "midi_effects" and hasattr(app.browser, 'midi_effects'): 984 | current_item = app.browser.midi_effects 985 | else: 986 | # Try to find the category in other browser attributes 987 | found = False 988 | for attr in browser_attrs: 989 | if attr.lower() == root_category: 990 | try: 991 | current_item = getattr(app.browser, attr) 992 | found = True 993 | break 994 | except Exception as e: 995 | self.log_message("Error accessing browser attribute {0}: {1}".format(attr, str(e))) 996 | 997 | if not found: 998 | # If we still haven't found the category, return available categories 999 | return { 1000 | "path": path, 1001 | "error": "Unknown or unavailable category: {0}".format(root_category), 1002 | "available_categories": browser_attrs, 1003 | "items": [] 1004 | } 1005 | 1006 | # Navigate through the path 1007 | for i in range(1, len(path_parts)): 1008 | part = path_parts[i] 1009 | if not part: # Skip empty parts 1010 | continue 1011 | 1012 | if not hasattr(current_item, 'children'): 1013 | return { 1014 | "path": path, 1015 | "error": "Item at '{0}' has no children".format('/'.join(path_parts[:i])), 1016 | "items": [] 1017 | } 1018 | 1019 | found = False 1020 | for child in current_item.children: 1021 | if hasattr(child, 'name') and child.name.lower() == part.lower(): 1022 | current_item = child 1023 | found = True 1024 | break 1025 | 1026 | if not found: 1027 | return { 1028 | "path": path, 1029 | "error": "Path part '{0}' not found".format(part), 1030 | "items": [] 1031 | } 1032 | 1033 | # Get items at the current path 1034 | items = [] 1035 | if hasattr(current_item, 'children'): 1036 | for child in current_item.children: 1037 | item_info = { 1038 | "name": child.name if hasattr(child, 'name') else "Unknown", 1039 | "is_folder": hasattr(child, 'children') and bool(child.children), 1040 | "is_device": hasattr(child, 'is_device') and child.is_device, 1041 | "is_loadable": hasattr(child, 'is_loadable') and child.is_loadable, 1042 | "uri": child.uri if hasattr(child, 'uri') else None 1043 | } 1044 | items.append(item_info) 1045 | 1046 | result = { 1047 | "path": path, 1048 | "name": current_item.name if hasattr(current_item, 'name') else "Unknown", 1049 | "uri": current_item.uri if hasattr(current_item, 'uri') else None, 1050 | "is_folder": hasattr(current_item, 'children') and bool(current_item.children), 1051 | "is_device": hasattr(current_item, 'is_device') and current_item.is_device, 1052 | "is_loadable": hasattr(current_item, 'is_loadable') and current_item.is_loadable, 1053 | "items": items 1054 | } 1055 | 1056 | self.log_message("Retrieved {0} items at path: {1}".format(len(items), path)) 1057 | return result 1058 | 1059 | except Exception as e: 1060 | self.log_message("Error getting browser items at path: {0}".format(str(e))) 1061 | self.log_message(traceback.format_exc()) 1062 | raise 1063 | ```