- MCP Server with audio fallback, sample management - Song generator with bus routing - Reference listener and audio resampler - Vector-based sample search - Master chain with limiter and calibration - Fix: Audio fallback now works without M4L - Fix: Full song detection in sample loader Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
944 lines
34 KiB
Python
944 lines
34 KiB
Python
"""
|
|
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
|
|
}
|
|
}
|