""" 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), }