""" AbletonMCP AI - Remote Script para Ableton Live 12 Integración completa con MCP para generación musical por IA Este script debe copiarse a: C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\ Y luego seleccionarse en Preferencias > Link/Tempo/MIDI > Control Surface """ from __future__ import absolute_import, print_function, unicode_literals from _Framework.ControlSurface import ControlSurface import socket import json import threading import time import traceback import os import hashlib # Python 2/3 compatibility try: import queue except ImportError: pass try: string_types = basestring except NameError: string_types = str # Configuración DEFAULT_PORT = 9877 HOST = "localhost" CONFIG_FILE = r"C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\track_config.json" def create_instance(c_instance): """Crea y retorna la instancia del script""" return AbletonMCP_AI(c_instance) class AbletonMCP_AI(ControlSurface): """ Remote Script para integración MCP + AI con Ableton Live 12 Características: - Servidor socket para comunicación con MCP Server - Generación de tracks MIDI con patrones automáticos - Carga de samples vía browser - Integración con análisis de audio por IA """ def __init__(self, c_instance): ControlSurface.__init__(self, c_instance) self.log_message("=" * 60) self.log_message("AbletonMCP AI - Inicializando...") self.log_message("=" * 60) # Referencia a la canción self._song = self.song() # Servidor socket self.server = None self.client_threads = [] self.server_thread = None self.running = False # Config watcher para generación automática self._last_config_hash = None self._config_watcher_thread = None self._config_watcher_running = False # Iniciar servidor self.start_server() # Iniciar watcher de configuración self.start_config_watcher() self.log_message("AbletonMCP AI inicializado correctamente") self.show_message("AbletonMCP AI: Listo en puerto " + str(DEFAULT_PORT)) def disconnect(self): """Llamado cuando Ableton cierra o se remueve el script""" self.log_message("AbletonMCP AI desconectando...") self.running = False self._config_watcher_running = False # Detener servidor if self.server: try: self.server.close() except Exception: pass # Esperar threads if self.server_thread and self.server_thread.is_alive(): self.server_thread.join(1.0) if self._config_watcher_thread and self._config_watcher_thread.is_alive(): self._config_watcher_thread.join(0.5) ControlSurface.disconnect(self) self.log_message("AbletonMCP AI desconectado") # ========================================================================= # SERVIDOR SOCKET # ========================================================================= def start_server(self): """Inicia el servidor socket en un thread separado""" 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, DEFAULT_PORT)) self.server.listen(5) self.running = True self.server_thread = threading.Thread(target=self._server_thread) self.server_thread.daemon = True self.server_thread.start() self.log_message("Servidor socket iniciado en puerto " + str(DEFAULT_PORT)) except Exception as e: self.log_message("Error iniciando servidor: " + str(e)) self.show_message("AbletonMCP AI Error: " + str(e)) def _server_thread(self): """Thread principal del servidor - maneja conexiones""" try: self.server.settimeout(1.0) while self.running: try: client, address = self.server.accept() self.log_message("Conexión aceptada de " + str(address)) # Manejar cliente en thread separado client_thread = threading.Thread( target=self._handle_client, args=(client,) ) client_thread.daemon = True client_thread.start() self.client_threads.append(client_thread) # Limpiar threads terminados self.client_threads = [t for t in self.client_threads if t.is_alive()] except socket.timeout: continue except Exception as e: if self.running: self.log_message("Error servidor: " + str(e)) time.sleep(0.5) except Exception as e: self.log_message("Error thread servidor: " + str(e)) def _handle_client(self, client): """Maneja comunicación con un cliente conectado""" client.settimeout(None) buffer = '' try: while self.running: try: data = client.recv(8192) if not data: self.log_message("Cliente desconectado") break # Acumular en buffer try: buffer += data.decode('utf-8') except AttributeError: buffer += data # Intentar parsear JSON try: command = json.loads(buffer) buffer = '' self.log_message("Comando recibido: " + str(command.get("type", "unknown"))) # Procesar comando response = self._process_command(command) # Enviar respuesta try: client.sendall(json.dumps(response).encode('utf-8')) except AttributeError: client.sendall(json.dumps(response)) except ValueError: # Datos incompletos, esperar más continue except Exception as e: self.log_message("Error manejando cliente: " + str(e)) error_response = {"status": "error", "message": str(e)} try: client.sendall(json.dumps(error_response).encode('utf-8')) except Exception: pass break finally: try: client.close() except Exception: pass # ========================================================================= # CONFIG WATCHER - Generación automática # ========================================================================= def start_config_watcher(self): """Inicia el watcher de configuración para generación automática""" self._config_watcher_running = True self._config_watcher_thread = threading.Thread(target=self._config_watcher_loop) self._config_watcher_thread.daemon = True self._config_watcher_thread.start() self.log_message("Config watcher iniciado") def _config_watcher_loop(self): """Loop que monitorea cambios en el archivo de configuración""" while self._config_watcher_running: try: if os.path.exists(CONFIG_FILE): with open(CONFIG_FILE, 'r') as f: content = f.read() h = hashlib.md5(content.encode()).hexdigest() if h != self._last_config_hash: self._last_config_hash = h self.log_message("Config cambiado - generando track...") try: config = json.loads(content) # Solo procesar si tiene flag 'auto_generate' if config.get('auto_generate', False): self._generate_from_config(config) except Exception as e: self.log_message("Error generando desde config: " + str(e)) self.log_message(traceback.format_exc()) time.sleep(1.0) # Revisar cada segundo except Exception as e: self.log_message("Error en config watcher: " + str(e)) time.sleep(2.0) def _generate_from_config(self, config): """Genera un track completo desde una configuración""" try: self.show_message("AI: Generando " + config.get('name', 'Track')) # 1. Limpiar proyecto existente self._clear_all_tracks() # 2. Setear BPM bpm = config.get('bpm', 128) self._song.tempo = bpm # 3. Crear tracks según configuración tracks_config = config.get('tracks', []) for idx, track_cfg in enumerate(tracks_config): track_type = track_cfg.get('type', 'midi') name = track_cfg.get('name', 'Track ' + str(idx)) if track_type == 'midi': self._song.create_midi_track(idx) elif track_type == 'audio': self._song.create_audio_track(idx) track = self._song.tracks[idx] track.name = name # Setear color si existe if 'color' in track_cfg: track.color = track_cfg['color'] # Crear clip con notas si existe configuración if 'clip' in track_cfg: clip_cfg = track_cfg['clip'] slot_idx = clip_cfg.get('slot', 0) length = clip_cfg.get('length', 4.0) # Asegurar que existan suficientes scenes while len(self._song.scenes) <= slot_idx: self._song.create_scene(-1) clip_slot = track.clip_slots[slot_idx] clip_slot.create_clip(length) # Agregar notas if 'notes' in clip_cfg: clip = clip_slot.clip for note in clip_cfg['notes']: pitch = note.get('pitch', 60) start = note.get('start', 0.0) duration = note.get('duration', 0.25) velocity = note.get('velocity', 100) clip.add_new_note((pitch, start, duration, velocity, False)) # Cargar instrumento si se especifica if 'instrument' in track_cfg: instrument_name = track_cfg['instrument'] # Usar browser para cargar self._load_instrument_by_name(track, instrument_name) self.show_message("AI: Track generado exitosamente!") self.log_message("Generación completada: " + str(len(tracks_config)) + " tracks") except Exception as e: self.log_message("Error en generación: " + str(e)) self.log_message(traceback.format_exc()) self.show_message("AI Error: " + str(e)) def _clear_all_tracks(self): """Elimina todos los tracks existentes""" try: while len(self._song.tracks) > 0: self._song.delete_track(len(self._song.tracks) - 1) except Exception as e: self.log_message("Error limpiando tracks: " + str(e)) def _load_instrument_by_name(self, track, name): """Carga un instrumento en el track por nombre""" try: browser = self.application().browser # Buscar en categorías de instrumentos if hasattr(browser, 'instruments'): for item in self._search_browser_items(browser.instruments, name): try: browser.load_item(item) self.log_message("Instrumento cargado: " + name) return True except Exception as e: self.log_message("Error cargando instrumento: " + str(e)) return False except Exception as e: self.log_message("Error buscando instrumento: " + str(e)) return False def _search_browser_items(self, root, name, depth=0, max_depth=5): """Busca items en el browser recursivamente""" if depth > max_depth or root is None: return [] results = [] try: # Verificar si el nombre coincide item_name = getattr(root, 'name', '').lower() if name.lower() in item_name or item_name in name.lower(): results.append(root) # Buscar en hijos if hasattr(root, 'children'): for child in root.children: results.extend(self._search_browser_items(child, name, depth + 1, max_depth)) except Exception: pass return results # ========================================================================= # PROCESAMIENTO DE COMANDOS # ========================================================================= def _process_command(self, command): """Procesa un comando recibido y retorna respuesta""" command_type = command.get("type", "") params = command.get("params", {}) try: # Comandos de información if command_type == "get_session_info": return self._cmd_get_session_info() elif command_type == "get_track_info": return self._cmd_get_track_info(params) elif command_type == "get_tracks": return self._cmd_get_tracks() # Comandos de tracks elif command_type == "create_midi_track": return self._cmd_create_midi_track(params) elif command_type == "create_audio_track": return self._cmd_create_audio_track(params) elif command_type == "set_track_name": return self._cmd_set_track_name(params) elif command_type == "set_track_volume": return self._cmd_set_track_volume(params) elif command_type == "set_track_pan": return self._cmd_set_track_pan(params) elif command_type == "set_track_mute": return self._cmd_set_track_mute(params) elif command_type == "set_track_solo": return self._cmd_set_track_solo(params) elif command_type == "set_track_color": return self._cmd_set_track_color(params) # Comandos de clips elif command_type == "create_clip": return self._cmd_create_clip(params) elif command_type == "add_notes_to_clip": return self._cmd_add_notes_to_clip(params) elif command_type == "set_clip_name": return self._cmd_set_clip_name(params) elif command_type == "set_clip_envelope": return self._cmd_set_clip_envelope(params) elif command_type == "fire_clip": return self._cmd_fire_clip(params) elif command_type == "stop_clip": return self._cmd_stop_clip(params) # Comandos de transporte elif command_type == "set_tempo": return self._cmd_set_tempo(params) elif command_type == "start_playback": return self._cmd_start_playback() elif command_type == "stop_playback": return self._cmd_stop_playback() # Comandos de escenas elif command_type == "create_scene": return self._cmd_create_scene(params) elif command_type == "set_scene_name": return self._cmd_set_scene_name(params) elif command_type == "fire_scene": return self._cmd_fire_scene(params) # Comandos de dispositivos elif command_type == "load_instrument_or_effect": return self._cmd_load_instrument(params) elif command_type == "set_device_parameter": return self._cmd_set_device_parameter(params) # Comando de generación AI elif command_type == "generate_track": return self._cmd_generate_track(params) else: return {"status": "error", "message": "Comando desconocido: " + command_type} except Exception as e: self.log_message("Error procesando comando " + command_type + ": " + str(e)) self.log_message(traceback.format_exc()) return {"status": "error", "message": str(e)} # ========================================================================= # IMPLEMENTACIÓN DE COMANDOS # ========================================================================= def _cmd_get_session_info(self): """Retorna información de la sesión actual""" return { "status": "success", "result": { "tempo": self._song.tempo, "signature_numerator": self._song.signature_numerator, "signature_denominator": self._song.signature_denominator, "is_playing": self._song.is_playing, "current_song_time": self._song.current_song_time, "loop_start": self._song.loop_start, "loop_length": self._song.loop_length, "num_tracks": len(self._song.tracks), "num_scenes": len(self._song.scenes), "num_return_tracks": len(self._song.return_tracks) } } def _cmd_get_track_info(self, params): """Retorna información de un track específico""" idx = params.get("track_index", 0) if idx < 0 or idx >= len(self._song.tracks): return {"status": "error", "message": "Track index fuera de rango"} track = self._song.tracks[idx] # Determinar tipo de track track_type = "unknown" if track.has_midi_input: track_type = "midi" elif track.has_audio_input: track_type = "audio" return { "status": "success", "result": { "index": idx, "name": track.name, "type": track_type, "color": track.color, "mute": track.mute, "solo": track.solo, "arm": track.arm, "volume": track.mixer_device.volume.value if track.mixer_device else 0.85, "pan": track.mixer_device.panning.value if track.mixer_device else 0.0, "num_clips": len(track.clip_slots), "num_devices": len(track.devices) } } def _cmd_get_tracks(self): """Retorna lista de todos los tracks""" tracks = [] for i, track in enumerate(self._song.tracks): track_type = "midi" if track.has_midi_input else "audio" if track.has_audio_input else "unknown" tracks.append({ "index": i, "name": track.name, "type": track_type, "color": track.color, "mute": track.mute, "solo": track.solo }) return {"status": "success", "result": tracks} def _cmd_create_midi_track(self, params): """Crea un track MIDI""" index = params.get("index", -1) self._song.create_midi_track(index) return {"status": "success", "result": {"message": "MIDI track creado", "index": index}} def _cmd_create_audio_track(self, params): """Crea un track de audio""" index = params.get("index", -1) self._song.create_audio_track(index) return {"status": "success", "result": {"message": "Audio track creado", "index": index}} def _cmd_set_track_name(self, params): """Setea el nombre de un track""" idx = params.get("track_index", 0) name = params.get("name", "Track") self._song.tracks[idx].name = name return {"status": "success", "result": {"message": "Nombre actualizado", "name": name}} def _cmd_set_track_volume(self, params): """Setea el volumen de un track""" idx = params.get("track_index", 0) volume = params.get("volume", 0.85) track = self._song.tracks[idx] if track.mixer_device and track.mixer_device.volume: track.mixer_device.volume.value = volume return {"status": "success"} def _cmd_set_track_pan(self, params): """Setea el pan de un track""" idx = params.get("track_index", 0) pan = params.get("pan", 0.0) track = self._song.tracks[idx] if track.mixer_device and track.mixer_device.panning: track.mixer_device.panning.value = pan return {"status": "success"} def _cmd_set_track_mute(self, params): """Setea el mute de un track""" idx = params.get("track_index", 0) mute = params.get("mute", True) track = self._song.tracks[idx] current_mute = track.mute if current_mute != mute: track.mute = mute return {"status": "success", "result": {"mute": track.mute, "track_index": idx}} def _cmd_set_track_solo(self, params): """Setea el solo de un track""" idx = params.get("track_index", 0) solo = params.get("solo", True) self._song.tracks[idx].solo = solo return {"status": "success"} def _cmd_set_track_color(self, params): """Setea el color de un track""" idx = params.get("track_index", 0) color = params.get("color", 0) self._song.tracks[idx].color = color return {"status": "success"} def _cmd_create_clip(self, params): """Crea un clip en un slot""" track_idx = params.get("track_index", 0) clip_idx = params.get("clip_index", 0) length = params.get("length", 4.0) track = self._song.tracks[track_idx] # Asegurar que existan suficientes scenes while len(self._song.scenes) <= clip_idx: self._song.create_scene(-1) clip_slot = track.clip_slots[clip_idx] clip_slot.create_clip(length) return {"status": "success", "result": {"message": "Clip creado"}} def _cmd_add_notes_to_clip(self, params): """Agrega notas a un clip MIDI""" track_idx = params.get("track_index", 0) clip_idx = params.get("clip_index", 0) notes = params.get("notes", []) track = self._song.tracks[track_idx] clip_slot = track.clip_slots[clip_idx] if not clip_slot.has_clip: return {"status": "error", "message": "No hay clip en este slot"} clip = clip_slot.clip for note in notes: pitch = note.get("pitch", 60) start = note.get("start", 0.0) duration = note.get("duration", 0.25) velocity = note.get("velocity", 100) clip.add_new_note((pitch, start, duration, velocity, False)) return {"status": "success", "result": {"num_notes_added": len(notes)}} def _cmd_set_clip_name(self, params): """Setea el nombre de un clip""" track_idx = params.get("track_index", 0) clip_idx = params.get("clip_index", 0) name = params.get("name", "Clip") clip_slot = self._song.tracks[track_idx].clip_slots[clip_idx] if clip_slot.has_clip: clip_slot.clip.name = name return {"status": "success"} def _cmd_fire_clip(self, params): """Dispara un clip""" track_idx = params.get("track_index", 0) clip_idx = params.get("clip_index", 0) clip_slot = self._song.tracks[track_idx].clip_slots[clip_idx] clip_slot.fire() return {"status": "success"} def _cmd_stop_clip(self, params): """Detiene un clip""" track_idx = params.get("track_index", 0) clip_idx = params.get("clip_index", 0) clip_slot = self._song.tracks[track_idx].clip_slots[clip_idx] clip_slot.stop() return {"status": "success"} def _cmd_set_tempo(self, params): """Setea el BPM""" tempo = params.get("tempo", 120.0) self._song.tempo = tempo return {"status": "success", "result": {"tempo": tempo}} def _cmd_start_playback(self): """Inicia reproducción""" self._song.start_playing() return {"status": "success"} def _cmd_stop_playback(self): """Detiene reproducción""" self._song.stop_playing() return {"status": "success"} def _cmd_create_scene(self, params): """Crea una scene""" index = params.get("index", -1) self._song.create_scene(index) return {"status": "success"} def _cmd_set_scene_name(self, params): """Setea el nombre de una scene""" idx = params.get("scene_index", 0) name = params.get("name", "Scene") self._song.scenes[idx].name = name return {"status": "success"} def _cmd_fire_scene(self, params): """Dispara una scene""" idx = params.get("scene_index", 0) scene = self._song.scenes[idx] scene.fire() if not self._song.is_playing: self._song.start_playing() return {"status": "success"} def _cmd_load_instrument(self, params): """Carga un instrumento en un track""" track_idx = params.get("track_index", 0) name = params.get("name", "") track = self._song.tracks[track_idx] success = self._load_instrument_by_name(track, name) if success: return {"status": "success", "result": {"message": "Instrumento cargado"}} else: return {"status": "error", "message": "No se pudo cargar el instrumento"} def _cmd_set_device_parameter(self, params): """Setea un parámetro de dispositivo""" track_idx = params.get("track_index", 0) device_idx = params.get("device_index", 0) param_idx = params.get("parameter_index", 0) value = params.get("value", 0.0) track = self._song.tracks[track_idx] device = track.devices[device_idx] param = device.parameters[param_idx] param.value = value return {"status": "success"} def _cmd_generate_track(self, params): """Comando principal de generación de tracks""" # Este comando delega a _generate_from_config # pero puede ser llamado directamente vía socket try: self._generate_from_config(params) return {"status": "success", "result": {"message": "Track generado exitosamente"}} except Exception as e: return {"status": "error", "message": str(e)} def _cmd_set_clip_envelope(self, params): """Setea un envelope (volume, pan, send) en un clip con puntos de automatización""" track_idx = params.get("track_index", 0) clip_idx = params.get("clip_index", 0) envelope_name = params.get("envelope", "volume") # volume, pan, send points = params.get("points", []) track = self._song.tracks[track_idx] clip_slot = track.clip_slots[clip_idx] if not clip_slot.has_clip: return {"status": "error", "message": "No hay clip en este slot"} clip = clip_slot.clip # Obtener el envelope correcto if envelope_name == "volume": envelope = clip.volume_envelope elif envelope_name == "pan": envelope = clip.pan_envelope elif envelope_name == "send": send_idx = params.get("send_index", 0) if send_idx < len(track.mixer_device.sends): envelope = track.mixer_device.sends[send_idx].envelope else: return {"status": "error", "message": "Send index fuera de rango"} else: return {"status": "error", "message": "Envelope type desconocido: " + envelope_name} # Limpiar puntos existentes si se especifica clear_existing = params.get("clear_existing", False) if clear_existing: while len(envelope.points) > 0: envelope.delete_point(len(envelope.points) - 1) # Agregar puntos de automatización desde el array de puntos if points: for point in points: if isinstance(point, dict): time_pos = point.get("time", 0.0) value = point.get("value", 0.0) envelope.add_new_point(time_pos, value) return {"status": "success", "result": {"message": "Envelope seteado con puntos", "points_added": len(points)}} else: return {"status": "error", "message": "No se especificaron puntos de automatización"} def _cmd_calibrate_track_gain(self, params): """Calibra el gain de un track basado en loudness""" track_idx = params.get("track_index", 0) target_loudness = params.get("target_loudness", -14.0) # LUFS target measurement_window = params.get("measurement_window", 0.1) # segundos track = self._song.tracks[track_idx] if not track.has_audio_input: return {"status": "error", "message": "Track no es de audio"} # Obtener el peak volume actual current_volume = track.mixer_device.volume.value # Calibrar para alcanzar el target (simplificado) # En una implementación real, usaríamos análisis de loudness real # Por ahora, ajustamos proporcionalmente adjustment = target_loudness / -20.0 # Aproximación new_volume = max(0.0, min(1.0, current_volume * adjustment)) track.mixer_device.volume.value = new_volume return { "status": "success", "result": { "message": "Gain calibrado", "current_volume": current_volume, "new_volume": new_volume, "target_loudness": target_loudness } } def _cmd_apply_compression(self, params): """Aplica compresión a un track""" track_idx = params.get("track_index", 0) threshold = params.get("threshold", -24.0) ratio = params.get("ratio", 4.0) attack = params.get("attack", 0.01) release = params.get("release", 0.1) track = self._song.tracks[track_idx] # Buscar o crear compressor compressor = None for device in track.devices: if device.name == "Compressor": compressor = device break if compressor is None: # Intentar cargar Compressor desde browser browser = self.application().browser for item in self._search_browser_items(browser.effects, "Compressor"): try: browser.load_item(item) compressor = track.devices[-1] break except Exception: pass if compressor: # Setear parámetros (índices pueden variar según versión) try: if len(compressor.parameters) > 0: compressor.parameters[0].value = threshold # Threshold if len(compressor.parameters) > 1: compressor.parameters[1].value = ratio # Ratio if len(compressor.parameters) > 2: compressor.parameters[2].value = attack # Attack if len(compressor.parameters) > 3: compressor.parameters[3].value = release # Release except Exception: pass return {"status": "success", "result": {"message": "Compresor aplicado"}} else: return {"status": "error", "message": "No se pudo cargar compresor"} def _cmd_apply_limiting(self, params): """Aplica limiting para loudness normalization""" track_idx = params.get("track_index", 0) target_loudness = params.get("target_loudness", -1.0) # LUFS para master lookahead = params.get("lookahead", 0.01) release = params.get("release", 0.05) track = self._song.tracks[track_idx] # Buscar o crear limiter limiter = None for device in track.devices: if "Limiter" in device.name: limiter = device break if limiter is None: # Intentar cargar Limiter desde browser browser = self.application().browser for item in self._search_browser_items(browser.effects, "Limiter"): try: browser.load_item(item) limiter = track.devices[-1] break except Exception: pass if limiter: # Setear parámetros try: if len(limiter.parameters) > 0: limiter.parameters[0].value = target_loudness # Gain if len(limiter.parameters) > 1: limiter.parameters[1].value = lookahead # Lookahead if len(limiter.parameters) > 2: limiter.parameters[2].value = release # Release except Exception: pass return {"status": "success", "result": {"message": "Limiter aplicado"}} else: return {"status": "error", "message": "No se pudo cargar limiter"} def _cmd_master_loudness_normalization(self, params): """Normaliza el loudness del master track""" track_idx = params.get("track_index", 0) target_loudness = params.get("target_loudness", -14.0) track = self._song.tracks[track_idx] # Calibrar gain current_volume = track.mixer_device.volume.value adjustment = 10 ** ((target_loudness - (-14)) / 20) # Aproximación new_volume = max(0.0, min(1.0, current_volume * adjustment)) track.mixer_device.volume.value = new_volume return { "status": "success", "result": { "message": "Loudness normalizado", "target_loudness": target_loudness, "new_volume": new_volume } }