Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 124 additions & 4 deletions AbletonMCP_Remote_Script/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -226,10 +226,13 @@ def _process_command(self, command):
track_index = params.get("track_index", 0)
response["result"] = self._get_track_info(track_index)
# Commands that modify Live's state should be scheduled on the main thread
elif command_type in ["create_midi_track", "set_track_name",
"create_clip", "add_notes_to_clip", "set_clip_name",
elif command_type in ["create_midi_track", "set_track_name",
"create_clip", "add_notes_to_clip", "set_clip_name",
"set_tempo", "fire_clip", "stop_clip",
"start_playback", "stop_playback", "load_browser_item"]:
"start_playback", "stop_playback", "load_browser_item",
# Arrangement view – must run on the main thread
"switch_to_arrangement_view", "set_current_song_time",
"duplicate_session_clip_to_arrangement"]:
# Use a thread-safe approach with a response queue
response_queue = queue.Queue()

Expand Down Expand Up @@ -282,7 +285,19 @@ def main_thread_task():
track_index = params.get("track_index", 0)
item_uri = params.get("item_uri", "")
result = self._load_browser_item(track_index, item_uri)

# ── Arrangement view commands ──────────────────────────────
elif command_type == "switch_to_arrangement_view":
result = self._switch_to_arrangement_view()
elif command_type == "set_current_song_time":
time_val = params.get("time", 0.0)
result = self._set_current_song_time(time_val)
elif command_type == "duplicate_session_clip_to_arrangement":
track_index = params.get("track_index", 0)
clip_index = params.get("clip_index", 0)
destination_time = params.get("destination_time", 0.0)
result = self._duplicate_session_clip_to_arrangement(
track_index, clip_index, destination_time)

# Put the result in the queue
response_queue.put({"status": "success", "result": result})
except Exception as e:
Expand Down Expand Up @@ -326,6 +341,10 @@ def main_thread_task():
elif command_type == "get_browser_items_at_path":
path = params.get("path", "")
response["result"] = self.get_browser_items_at_path(path)
# Read-only arrangement command – no main-thread scheduling required
elif command_type == "get_arrangement_clips":
track_index = params.get("track_index", 0)
response["result"] = self._get_arrangement_clips(track_index)
else:
response["status"] = "error"
response["message"] = "Unknown command: " + command_type
Expand Down Expand Up @@ -637,6 +656,107 @@ def _stop_playback(self):
self.log_message("Error stopping playback: " + str(e))
raise

# ── Arrangement view implementations ──────────────────────────────────────

def _switch_to_arrangement_view(self):
"""Switch Ableton's main window to the Arrangement view"""
try:
self.application().view.show_view("Arranger")
return {"view": "Arranger"}
except Exception as e:
self.log_message("Error switching to arrangement view: " + str(e))
raise

def _set_current_song_time(self, time_val):
"""Move the arrangement playhead to a position in beats"""
try:
self._song.current_song_time = float(time_val)
return {"current_song_time": self._song.current_song_time}
except Exception as e:
self.log_message("Error setting current song time: " + str(e))
raise

def _get_arrangement_clips(self, track_index):
"""Return all clips placed in the Arrangement timeline for a track.

Each clip dict contains:
name, start_time, end_time, length, color,
is_midi_clip, is_audio_clip, is_playing
"""
try:
if track_index < 0 or track_index >= len(self._song.tracks):
raise IndexError("Track index out of range")

track = self._song.tracks[track_index]
clips = []

# track.arrangement_clips is available in Live 11 / 12
for clip in track.arrangement_clips:
clips.append({
"name": clip.name,
"start_time": clip.start_time,
"end_time": clip.end_time,
"length": clip.length,
"color": clip.color,
"is_midi_clip": clip.is_midi_clip,
"is_audio_clip": clip.is_audio_clip,
"is_playing": clip.is_playing
})

return {
"track_index": track_index,
"track_name": track.name,
"clip_count": len(clips),
"clips": clips
}
except Exception as e:
self.log_message("Error getting arrangement clips: " + str(e))
raise

def _duplicate_session_clip_to_arrangement(self, track_index, clip_index, destination_time):
"""Copy a Session-view clip into the Arrangement timeline.

Uses the real Live API:
track.duplicate_clip_to_arrangement(clip, destination_time)

Available in Live 11 / 12. destination_time is in beats from the
start of the arrangement.
"""
try:
if track_index < 0 or track_index >= len(self._song.tracks):
raise IndexError("Track index out of range")

track = self._song.tracks[track_index]

if clip_index < 0 or clip_index >= len(track.clip_slots):
raise IndexError("Clip slot index out of range")

clip_slot = track.clip_slots[clip_index]

if not clip_slot.has_clip:
raise Exception(
"No clip in slot " + str(clip_index) +
" on track " + str(track_index)
)

clip = clip_slot.clip

# Duplicate to arrangement at the requested beat position
track.duplicate_clip_to_arrangement(clip, float(destination_time))

return {
"success": True,
"track_index": track_index,
"track_name": track.name,
"clip_name": clip.name,
"destination_time": destination_time
}
except Exception as e:
self.log_message("Error duplicating clip to arrangement: " + str(e))
raise

# ── Browser implementations ───────────────────────────────────────────────

def _get_browser_item(self, uri, path):
"""Get a browser item by URI or path"""
try:
Expand Down
117 changes: 103 additions & 14 deletions MCP_Server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,10 @@ def send_command(self, command_type: str, params: Dict[str, Any] = None) -> Dict
"create_midi_track", "create_audio_track", "set_track_name",
"create_clip", "add_notes_to_clip", "set_clip_name",
"set_tempo", "fire_clip", "stop_clip", "set_device_parameter",
"start_playback", "stop_playback", "load_instrument_or_effect"
"start_playback", "stop_playback", "load_instrument_or_effect",
# Arrangement view commands
"switch_to_arrangement_view", "set_current_song_time",
"duplicate_session_clip_to_arrangement"
]

try:
Expand All @@ -115,32 +118,22 @@ def send_command(self, command_type: str, params: Dict[str, Any] = None) -> Dict
self.sock.sendall(json.dumps(command).encode('utf-8'))
logger.info(f"Command sent, waiting for response...")

# For state-modifying commands, add a small delay to give Ableton time to process
if is_modifying_command:
import time
time.sleep(0.1) # 100ms delay

# Set timeout based on command type
timeout = 15.0 if is_modifying_command else 10.0
self.sock.settimeout(timeout)

# Receive the response
response_data = self.receive_full_response(self.sock)
logger.info(f"Received {len(response_data)} bytes of data")

# Parse the response
response = json.loads(response_data.decode('utf-8'))
logger.info(f"Response parsed, status: {response.get('status', 'unknown')}")

if response.get("status") == "error":
logger.error(f"Ableton error: {response.get('message')}")
raise Exception(response.get("message", "Unknown error from Ableton"))

# For state-modifying commands, add another small delay after receiving response
if is_modifying_command:
import time
time.sleep(0.1) # 100ms delay

return response.get("result", {})
except socket.timeout:
logger.error("Socket timeout while waiting for response from Ableton")
Expand Down Expand Up @@ -651,6 +644,102 @@ def load_drum_kit(ctx: Context, track_index: int, rack_uri: str, kit_path: str)
logger.error(f"Error loading drum kit: {str(e)}")
return f"Error loading drum kit: {str(e)}"

# ── Arrangement view tools ────────────────────────────────────────────────────

@mcp.tool()
def switch_to_arrangement_view(ctx: Context) -> str:
"""Switch Ableton's main window to the Arrangement view."""
try:
ableton = get_ableton_connection()
ableton.send_command("switch_to_arrangement_view")
return "Switched to Arrangement view"
except Exception as e:
logger.error(f"Error switching to arrangement view: {str(e)}")
return f"Error switching to arrangement view: {str(e)}"


@mcp.tool()
def set_arrangement_time(ctx: Context, time: float) -> str:
"""
Move the arrangement playhead to a specific position.

Parameters:
- time: Position in beats from the start of the arrangement (e.g. 8.0 = bar 3 in 4/4)
"""
try:
ableton = get_ableton_connection()
result = ableton.send_command("set_current_song_time", {"time": time})
return f"Playhead moved to beat {result.get('current_song_time', time)}"
except Exception as e:
logger.error(f"Error setting arrangement time: {str(e)}")
return f"Error setting arrangement time: {str(e)}"


@mcp.tool()
def get_arrangement_clips(ctx: Context, track_index: int) -> str:
"""
List all clips placed in the Arrangement timeline for a track.

Returns each clip's name, start_time, end_time, length, and type.

Parameters:
- track_index: The index of the track to inspect
"""
try:
ableton = get_ableton_connection()
result = ableton.send_command("get_arrangement_clips", {"track_index": track_index})
return json.dumps(result, indent=2)
except Exception as e:
logger.error(f"Error getting arrangement clips: {str(e)}")
return f"Error getting arrangement clips: {str(e)}"


@mcp.tool()
def duplicate_to_arrangement(
ctx: Context,
track_index: int,
clip_index: int,
destination_time: float
) -> str:
"""
Copy a Session-view clip into the Arrangement timeline.

Uses Live's track.duplicate_clip_to_arrangement() API (Live 11 / 12).
The clip is placed at destination_time beats from the start of the
arrangement on the same track it lives in.

Typical workflow:
1. create_clip / add_notes_to_clip to build a Session clip
2. Call duplicate_to_arrangement once per bar/section you need
3. Call switch_to_arrangement_view to confirm the result in Live

Parameters:
- track_index: Index of the track that owns the Session clip
- clip_index: Index of the clip slot in that track (Session view)
- destination_time: Beat position in the arrangement to place the clip
(e.g. 0.0 = start, 8.0 = bar 3 in 4/4)
"""
try:
ableton = get_ableton_connection()
result = ableton.send_command(
"duplicate_session_clip_to_arrangement",
{
"track_index": track_index,
"clip_index": clip_index,
"destination_time": destination_time
}
)
clip_name = result.get("clip_name", "clip")
track_name = result.get("track_name", f"track {track_index}")
return (
f"Duplicated '{clip_name}' from Session slot {clip_index} "
f"on '{track_name}' to arrangement at beat {destination_time}"
)
except Exception as e:
logger.error(f"Error duplicating clip to arrangement: {str(e)}")
return f"Error duplicating clip to arrangement: {str(e)}"


# Main execution
def main():
"""Run the MCP server"""
Expand Down
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# AbletonMCP - Ableton Live Model Context Protocol Integration
[![smithery badge](https://smithery.ai/badge/@ahujasid/ableton-mcp)](https://smithery.ai/server/@ahujasid/ableton-mcp)

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.
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, end-to-end track creation, and Live session and arrangement manipulation.

### Join the Community

Expand All @@ -13,7 +13,8 @@ Give feedback, get inspired, and build on top of the MCP: [Discord](https://disc
- **Track manipulation**: Create, modify, and manipulate MIDI and audio tracks
- **Instrument and effect selection**: Claude can access and load the right instruments, effects and sounds from Ableton's library
- **Clip creation**: Create and edit MIDI clips with notes
- **Session control**: Start and stop playback, fire clips, and control transport
- **Arrangement view composition**: Build full songs autonomously in Arrangement View, including sections like intro, buildup, drop, breakdown, and outro
- **Session control**: Start and stop playback, fire clips, and control transport across Session View and Arrangement View

## Components

Expand Down Expand Up @@ -125,6 +126,7 @@ Once the config file has been set on Claude, and the remote script is running in

- Get session and track information
- Create and modify MIDI and audio tracks
- Create full song arrangements from start to finish in Arrangement View
- Create, edit, and trigger clips
- Control playback
- Load instruments and effects from Ableton's browser
Expand All @@ -137,6 +139,7 @@ Here are some examples of what you can ask Claude to do:

- "Create an 80s synthwave track" [Demo](https://youtu.be/VH9g66e42XA)
- "Create a Metro Boomin style hip-hop beat"
- "Create a full arrangement with an intro, buildup, drop, breakdown, and outro"
- "Create a new MIDI track with a synth bass instrument"
- "Add reverb to my drums"
- "Create a 4-bar MIDI clip with a simple melody"
Expand Down