#
tokens: 21765/50000 9/9 files
lines: on (toggle) GitHub
raw markdown copy reset
# 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 | [![smithery badge](https://smithery.ai/badge/@ahujasid/ableton-mcp)](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 | 
```