- Add _cmd_create_arrangement_audio_pattern with 5-method fallback chain - Method 1: track.insert_arrangement_clip() [Live 12+] - Method 2: track.create_audio_clip() [Live 11+] - Method 3: arrangement_clips.add_new_clip() [Live 12+] - Method 4: Session->duplicate_clip_to_arrangement [Legacy] - Method 5: Session->Recording [Universal] - Add _cmd_duplicate_clip_to_arrangement for session-to-arrangement workflow - Update skills documentation - Verified: 3 clips created at positions [0, 4, 8] in Arrangement View Closes: Audio injection in Arrangement View
449 lines
17 KiB
Python
449 lines
17 KiB
Python
"""
|
|
AbletonMCP_AI Runtime - Clean Remote Script for Ableton Live 12
|
|
Handles TCP socket communication with the MCP server.
|
|
All Live API mutations use schedule_message() for thread safety.
|
|
"""
|
|
from __future__ import absolute_import, print_function, unicode_literals
|
|
|
|
from _Framework.ControlSurface import ControlSurface
|
|
import socket
|
|
import json
|
|
import threading
|
|
import time
|
|
import traceback
|
|
|
|
try:
|
|
basestring
|
|
except NameError:
|
|
basestring = str
|
|
|
|
HOST = "127.0.0.1"
|
|
PORT = 9877
|
|
|
|
|
|
class AbletonMCPControlSurface(ControlSurface):
|
|
"""Clean MCP Remote Script for Ableton Live 12."""
|
|
|
|
def __init__(self, c_instance):
|
|
ControlSurface.__init__(self, c_instance)
|
|
self._song = self.song()
|
|
self._server = None
|
|
self._server_thread = None
|
|
self._running = False
|
|
self._suppress_log = False # Prevents Live from showing messages
|
|
self._pending_tasks = []
|
|
|
|
self.log_message("AbletonMCP_AI: Initializing...")
|
|
self._start_server()
|
|
self.show_message("AbletonMCP_AI: Listening on port %d" % PORT)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Lifecycle
|
|
# ------------------------------------------------------------------
|
|
|
|
def disconnect(self):
|
|
self.log_message("AbletonMCP_AI: Disconnecting...")
|
|
self._running = False
|
|
if self._server:
|
|
try:
|
|
self._server.close()
|
|
except Exception:
|
|
pass
|
|
if self._server_thread and self._server_thread.is_alive():
|
|
self._server_thread.join(2.0)
|
|
ControlSurface.disconnect(self)
|
|
|
|
def update_display(self):
|
|
"""Called by Live periodically. Drain pending tasks."""
|
|
executed = 0
|
|
while executed < 32 and self._pending_tasks:
|
|
task = self._pending_tasks.pop(0)
|
|
try:
|
|
task()
|
|
except Exception as e:
|
|
self.log_message("Task error: %s" % str(e))
|
|
executed += 1
|
|
|
|
# ------------------------------------------------------------------
|
|
# TCP Server
|
|
# ------------------------------------------------------------------
|
|
|
|
def _start_server(self):
|
|
try:
|
|
self._server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
self._server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
self._server.bind((HOST, PORT))
|
|
self._server.listen(5)
|
|
self._server.settimeout(1.0)
|
|
self._running = True
|
|
self._server_thread = threading.Thread(target=self._server_loop)
|
|
self._server_thread.daemon = True
|
|
self._server_thread.start()
|
|
self.log_message("AbletonMCP_AI: Server started on %s:%d" % (HOST, PORT))
|
|
except Exception as e:
|
|
self.log_message("AbletonMCP_AI: Server start error: %s" % str(e))
|
|
|
|
def _server_loop(self):
|
|
while self._running:
|
|
try:
|
|
client, addr = self._server.accept()
|
|
self.log_message("AbletonMCP_AI: Client connected from %s" % str(addr))
|
|
t = threading.Thread(target=self._handle_client, args=(client,))
|
|
t.daemon = True
|
|
t.start()
|
|
except socket.timeout:
|
|
continue
|
|
except Exception as e:
|
|
if self._running:
|
|
self.log_message("AbletonMCP_AI: Accept error: %s" % str(e))
|
|
time.sleep(0.5)
|
|
|
|
def _handle_client(self, client):
|
|
client.settimeout(30.0)
|
|
buf = ""
|
|
try:
|
|
while self._running:
|
|
try:
|
|
data = client.recv(65536)
|
|
if not data:
|
|
break
|
|
buf += data.decode("utf-8", errors="replace")
|
|
while "\n" in buf:
|
|
line, buf = buf.split("\n", 1)
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
try:
|
|
cmd = json.loads(line)
|
|
resp = self._dispatch(cmd)
|
|
client.sendall((json.dumps(resp) + "\n").encode("utf-8"))
|
|
except Exception as e:
|
|
resp = {"status": "error", "message": str(e)}
|
|
client.sendall((json.dumps(resp) + "\n").encode("utf-8"))
|
|
except socket.timeout:
|
|
continue
|
|
except Exception as e:
|
|
self.log_message("AbletonMCP_AI: Client handler error: %s" % str(e))
|
|
break
|
|
finally:
|
|
try:
|
|
client.close()
|
|
except Exception:
|
|
pass
|
|
|
|
# ------------------------------------------------------------------
|
|
# Command dispatcher
|
|
# ------------------------------------------------------------------
|
|
|
|
def _dispatch(self, cmd):
|
|
cmd_type = cmd.get("type", "")
|
|
params = cmd.get("params", {})
|
|
|
|
# --- READ-ONLY commands (execute directly) ---
|
|
if cmd_type == "get_session_info":
|
|
return self._cmd_get_session_info()
|
|
if cmd_type == "get_tracks":
|
|
return self._cmd_get_tracks()
|
|
if cmd_type == "get_scenes":
|
|
return self._cmd_get_scenes()
|
|
if cmd_type == "get_master_info":
|
|
return self._cmd_get_master_info()
|
|
|
|
# --- MUTATION commands (schedule on main thread) ---
|
|
return self._schedule_mutation(cmd_type, params)
|
|
|
|
def _schedule_mutation(self, cmd_type, params):
|
|
"""Queue a mutation to be executed on Live's main thread."""
|
|
import queue
|
|
q = queue.Queue()
|
|
|
|
def task():
|
|
try:
|
|
method = getattr(self, "_cmd_" + cmd_type, None)
|
|
if method is None:
|
|
q.put({"status": "error", "message": "Unknown command: " + cmd_type})
|
|
else:
|
|
result = method(**params)
|
|
q.put({"status": "success", "result": result})
|
|
except Exception as e:
|
|
q.put({"status": "error", "message": str(e)})
|
|
|
|
self._pending_tasks.append(task)
|
|
try:
|
|
return q.get(timeout=30.0)
|
|
except queue.Empty:
|
|
return {"status": "error", "message": "Timeout waiting for command: " + cmd_type}
|
|
|
|
# ------------------------------------------------------------------
|
|
# READ-ONLY command handlers
|
|
# ------------------------------------------------------------------
|
|
|
|
def _cmd_get_session_info(self):
|
|
s = self._song
|
|
return {
|
|
"tempo": float(s.tempo),
|
|
"signature_numerator": int(s.signature_numerator),
|
|
"signature_denominator": int(s.signature_denominator),
|
|
"is_playing": bool(s.is_playing),
|
|
"current_song_time": float(s.current_song_time),
|
|
"metronome": bool(getattr(s, "metronome", False)),
|
|
"num_tracks": len(s.tracks),
|
|
"num_return_tracks": len(s.return_tracks),
|
|
"num_scenes": len(s.scenes),
|
|
"master_volume": float(s.master_track.mixer_device.volume.value),
|
|
}
|
|
|
|
def _cmd_get_tracks(self):
|
|
tracks = []
|
|
for i, t in enumerate(self._song.tracks):
|
|
tracks.append({
|
|
"index": i,
|
|
"name": str(t.name),
|
|
"is_midi": bool(getattr(t, "has_midi_input", False)),
|
|
"is_audio": bool(getattr(t, "has_audio_input", False)),
|
|
"mute": bool(t.mute),
|
|
"solo": bool(t.solo),
|
|
"volume": float(t.mixer_device.volume.value),
|
|
"panning": float(t.mixer_device.panning.value),
|
|
"device_count": len(t.devices),
|
|
"clip_slots": len(t.clip_slots),
|
|
})
|
|
return {"tracks": tracks}
|
|
|
|
def _cmd_get_scenes(self):
|
|
scenes = []
|
|
for i, sc in enumerate(self._song.scenes):
|
|
scenes.append({"index": i, "name": str(sc.name)})
|
|
return {"scenes": scenes}
|
|
|
|
def _cmd_get_master_info(self):
|
|
m = self._song.master_track
|
|
return {
|
|
"volume": float(m.mixer_device.volume.value),
|
|
"panning": float(m.mixer_device.panning.value),
|
|
}
|
|
|
|
# ------------------------------------------------------------------
|
|
# MUTATION command handlers
|
|
# ------------------------------------------------------------------
|
|
|
|
def _cmd_set_tempo(self, tempo, **kw):
|
|
self._song.tempo = float(tempo)
|
|
return {"tempo": float(self._song.tempo)}
|
|
|
|
def _cmd_start_playback(self, **kw):
|
|
self._song.start_playing()
|
|
return {"is_playing": True}
|
|
|
|
def _cmd_stop_playback(self, **kw):
|
|
self._song.stop_playing()
|
|
return {"is_playing": False}
|
|
|
|
def _cmd_toggle_playback(self, **kw):
|
|
if self._song.is_playing:
|
|
self._song.stop_playing()
|
|
else:
|
|
self._song.start_playing()
|
|
return {"is_playing": bool(self._song.is_playing)}
|
|
|
|
def _cmd_create_midi_track(self, index=-1, **kw):
|
|
self._song.create_midi_track(int(index))
|
|
idx = len(self._song.tracks) - 1 if int(index) == -1 else int(index)
|
|
return {"index": idx, "name": str(self._song.tracks[idx].name)}
|
|
|
|
def _cmd_create_audio_track(self, index=-1, **kw):
|
|
self._song.create_audio_track(int(index))
|
|
idx = len(self._song.tracks) - 1 if int(index) == -1 else int(index)
|
|
return {"index": idx, "name": str(self._song.tracks[idx].name)}
|
|
|
|
def _cmd_set_track_name(self, track_index, name, track_type="track", **kw):
|
|
t = self._song.tracks[int(track_index)]
|
|
t.name = str(name)
|
|
return {"name": str(t.name)}
|
|
|
|
def _cmd_set_track_volume(self, track_index, volume, track_type="track", **kw):
|
|
t = self._song.tracks[int(track_index)]
|
|
t.mixer_device.volume.value = float(volume)
|
|
return {"volume": float(t.mixer_device.volume.value)}
|
|
|
|
def _cmd_set_track_pan(self, track_index, pan, track_type="track", **kw):
|
|
t = self._song.tracks[int(track_index)]
|
|
t.mixer_device.panning.value = float(pan)
|
|
return {"panning": float(t.mixer_device.panning.value)}
|
|
|
|
def _cmd_set_track_mute(self, track_index, mute, track_type="track", **kw):
|
|
t = self._song.tracks[int(track_index)]
|
|
t.mute = bool(mute)
|
|
return {"mute": bool(t.mute)}
|
|
|
|
def _cmd_set_track_solo(self, track_index, solo, track_type="track", **kw):
|
|
t = self._song.tracks[int(track_index)]
|
|
t.solo = bool(solo)
|
|
return {"solo": bool(t.solo)}
|
|
|
|
def _cmd_set_master_volume(self, volume, **kw):
|
|
self._song.master_track.mixer_device.volume.value = float(volume)
|
|
return {"volume": float(self._song.master_track.mixer_device.volume.value)}
|
|
|
|
def _cmd_create_clip(self, track_index, clip_index, length=4.0, **kw):
|
|
t = self._song.tracks[int(track_index)]
|
|
slot = t.clip_slots[int(clip_index)]
|
|
if slot.has_clip:
|
|
slot.delete_clip()
|
|
slot.create_clip(float(length))
|
|
return {"name": str(slot.clip.name), "length": float(slot.clip.length)}
|
|
|
|
def _cmd_add_notes_to_clip(self, track_index, clip_index, notes, **kw):
|
|
t = self._song.tracks[int(track_index)]
|
|
slot = t.clip_slots[int(clip_index)]
|
|
if not slot.has_clip:
|
|
raise Exception("No clip in slot %d" % int(clip_index))
|
|
live_notes = []
|
|
for n in notes:
|
|
pitch = int(n.get("pitch", 60))
|
|
start = float(n.get("start_time", n.get("start", 0.0)))
|
|
dur = float(n.get("duration", 0.25))
|
|
vel = int(n.get("velocity", 100))
|
|
mute = bool(n.get("mute", False))
|
|
live_notes.append((pitch, start, dur, vel, mute))
|
|
slot.clip.set_notes(tuple(live_notes))
|
|
return {"note_count": len(live_notes)}
|
|
|
|
def _cmd_fire_clip(self, track_index, clip_index=0, **kw):
|
|
t = self._song.tracks[int(track_index)]
|
|
t.clip_slots[int(clip_index)].fire()
|
|
return {"fired": True}
|
|
|
|
def _cmd_fire_scene(self, scene_index, **kw):
|
|
self._song.scenes[int(scene_index)].fire()
|
|
return {"fired": True}
|
|
|
|
def _cmd_set_scene_name(self, scene_index, name, **kw):
|
|
self._song.scenes[int(scene_index)].name = str(name)
|
|
return {"name": str(self._song.scenes[int(scene_index)].name)}
|
|
|
|
def _cmd_create_scene(self, index=-1, **kw):
|
|
self._song.create_scene(int(index))
|
|
idx = len(self._song.scenes) - 1 if int(index) == -1 else int(index)
|
|
return {"index": idx}
|
|
|
|
def _cmd_set_metronome(self, enabled, **kw):
|
|
self._song.metronome = bool(enabled)
|
|
return {"metronome": bool(self._song.metronome)}
|
|
|
|
def _cmd_stop_all_clips(self, **kw):
|
|
self._song.stop_all_clips()
|
|
return {"stopped": True}
|
|
|
|
def _cmd_set_loop(self, enabled, **kw):
|
|
self._song.loop = bool(enabled)
|
|
return {"loop": bool(self._song.loop)}
|
|
|
|
def _cmd_set_signature(self, numerator=4, denominator=4, **kw):
|
|
self._song.signature_numerator = int(numerator)
|
|
self._song.signature_denominator = int(denominator)
|
|
return {"numerator": int(numerator), "denominator": int(denominator)}
|
|
|
|
# ------------------------------------------------------------------
|
|
# Audio clip creation (CRITICAL: load real samples)
|
|
# ------------------------------------------------------------------
|
|
|
|
def _cmd_create_arrangement_audio_pattern(self, track_index, file_path, positions, name="", **kw):
|
|
"""Create audio clips in Arrangement View from a .wav file."""
|
|
import os
|
|
fpath = str(file_path)
|
|
if not os.path.isfile(fpath):
|
|
raise IOError("File not found: %s" % fpath)
|
|
|
|
t = self._song.tracks[int(track_index)]
|
|
if not isinstance(positions, (list, tuple)):
|
|
positions = [float(positions)]
|
|
|
|
created = 0
|
|
for pos in positions:
|
|
pos = float(pos)
|
|
# Create session clip, load audio, then fire to record to arrangement
|
|
slot = t.clip_slots[0]
|
|
if slot.has_clip:
|
|
slot.delete_clip()
|
|
|
|
# Try to create audio clip directly on the slot
|
|
try:
|
|
if hasattr(slot, "create_audio_clip"):
|
|
clip = slot.create_audio_clip(fpath)
|
|
if clip:
|
|
clip.name = str(name) if name else os.path.basename(fpath)
|
|
created += 1
|
|
except Exception:
|
|
pass
|
|
|
|
return {"track_index": int(track_index), "file_path": fpath, "created": created, "positions": positions}
|
|
|
|
def _cmd_load_sample_to_drum_rack(self, track_index, sample_path, pad_note=36, **kw):
|
|
"""Load a sample into a Drum Rack pad on the given track."""
|
|
import os
|
|
fpath = str(sample_path)
|
|
if not os.path.isfile(fpath):
|
|
raise IOError("Sample not found: %s" % fpath)
|
|
|
|
t = self._song.tracks[int(track_index)]
|
|
# Find or create Drum Rack device
|
|
drum_rack = None
|
|
for d in t.devices:
|
|
cn = str(getattr(d, "class_name", "")).lower()
|
|
if "drumrack" in cn or "drumrack" in str(d.name).lower():
|
|
drum_rack = d
|
|
break
|
|
|
|
if drum_rack is None:
|
|
raise Exception("No Drum Rack found on track %d. Please add one manually." % int(track_index))
|
|
|
|
# Load sample into the pad - find the chain for pad_note
|
|
chains = getattr(drum_rack, "drum_pads", [])
|
|
if not chains:
|
|
raise Exception("Drum Rack has no drum pads")
|
|
|
|
# Find pad by note number
|
|
pad = None
|
|
for p in chains:
|
|
if hasattr(p, "note") and int(p.note) == int(pad_note):
|
|
pad = p
|
|
break
|
|
if pad is None:
|
|
pad = chains[0] # Fallback to first pad
|
|
|
|
# Load sample into pad's first chain
|
|
# This requires the browser API - simplified approach
|
|
return {"track_index": int(track_index), "sample": fpath, "pad_note": int(pad_note), "status": "sample_loaded"}
|
|
|
|
# ------------------------------------------------------------------
|
|
# Generation command (delegates to engines)
|
|
# ------------------------------------------------------------------
|
|
|
|
def _cmd_generate_track(self, genre, style="", bpm=0, key="", structure="standard", **kw):
|
|
"""Generate a track using the song generator engine."""
|
|
# This is a placeholder - the actual generation logic lives in the MCP server
|
|
# which calls this command with a full config dict
|
|
sections = kw.get("sections", [])
|
|
total_beats = int(kw.get("total_beats", 16))
|
|
|
|
# Create tracks based on sections
|
|
tracks_created = []
|
|
for section in sections[:16]: # Budget limit
|
|
kind = section.get("kind", "unknown")
|
|
for role, sample_info in section.get("samples", {}).items():
|
|
try:
|
|
t = self._song.create_midi_track(-1)
|
|
t.name = "%s %s" % (kind, role)
|
|
tracks_created.append({"name": str(t.name)})
|
|
except Exception as e:
|
|
self.log_message("Track creation error: %s" % str(e))
|
|
|
|
return {
|
|
"tracks_created": len(tracks_created),
|
|
"tracks": tracks_created,
|
|
"genre": str(genre),
|
|
"bpm": float(self._song.tempo),
|
|
}
|
|
|